From 417f7398e447c0b5735f0be467c0041982886d8e Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 25 Aug 2026 15:53:10 +0800 Subject: [PATCH] =?UTF-8?q?perf(chat):=20=E4=BC=98=E5=8C=96=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E5=AE=A2=E6=9C=8D=E4=BC=9A=E8=AF=9D=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E4=B8=8E=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/database/test_helper.go | 1 + backend/internal/model/chat.go | 17 ++ .../internal/modules/chat/admin_list_test.go | 106 +++++++ backend/internal/modules/chat/conversation.go | 16 +- .../modules/chat/conversation_test.go | 1 + backend/internal/modules/chat/dto.go | 1 + backend/internal/modules/chat/message.go | 52 +++- backend/internal/modules/chat/participant.go | 27 +- backend/internal/modules/chat/quick_reply.go | 28 +- backend/internal/modules/chat/service.go | 4 + backend/internal/modules/chat/support.go | 260 +++++++++++------- .../000056_chat_admin_state_and_indexes.sql | 67 +++++ .../features/admin/views/AdminChatsView.vue | 21 +- frontend/src/features/chats/api/chats.ts | 1 + 14 files changed, 461 insertions(+), 141 deletions(-) create mode 100644 backend/internal/modules/chat/admin_list_test.go create mode 100644 backend/migrations/000056_chat_admin_state_and_indexes.sql diff --git a/backend/internal/database/test_helper.go b/backend/internal/database/test_helper.go index c36a41f..11064fd 100644 --- a/backend/internal/database/test_helper.go +++ b/backend/internal/database/test_helper.go @@ -63,6 +63,7 @@ func MigrateRentalTransactionTestSchema(db *gorm.DB) error { &model.AuditLog{}, &model.ChatConversation{}, &model.ChatParticipant{}, + &model.ChatAdminConversationState{}, &model.ChatMessage{}, ) } diff --git a/backend/internal/model/chat.go b/backend/internal/model/chat.go index afe25c1..d21de10 100644 --- a/backend/internal/model/chat.go +++ b/backend/internal/model/chat.go @@ -42,6 +42,23 @@ func (ChatParticipant) TableName() string { return "chat_participants" } +// ChatAdminConversationState stores per-admin state even when the admin is not +// an assigned participant of the conversation. +type ChatAdminConversationState struct { + ID uint64 `gorm:"primaryKey" json:"id"` + ConversationID uint64 `gorm:"not null;uniqueIndex:uk_chat_admin_conversation_state;index" json:"conversation_id"` + AdminUserID uint64 `gorm:"not null;uniqueIndex:uk_chat_admin_conversation_state;index" json:"admin_user_id"` + Remark string `gorm:"size:128;not null;default:''" json:"remark"` + LastReadMessageID uint64 `gorm:"not null;default:0" json:"last_read_message_id"` + LastReadAt *time.Time `json:"last_read_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (ChatAdminConversationState) TableName() string { + return "chat_admin_conversation_states" +} + type ChatMessage struct { ID uint64 `gorm:"primaryKey" json:"id"` ConversationID uint64 `gorm:"not null;index" json:"conversation_id"` diff --git a/backend/internal/modules/chat/admin_list_test.go b/backend/internal/modules/chat/admin_list_test.go new file mode 100644 index 0000000..c4384f5 --- /dev/null +++ b/backend/internal/modules/chat/admin_list_test.go @@ -0,0 +1,106 @@ +package chat + +import ( + "testing" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func setupAdminListTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + if err := db.AutoMigrate( + &model.User{}, + &model.AdminUser{}, + &model.RentalListing{}, + &model.RentalOrder{}, + &model.ChatConversation{}, + &model.ChatParticipant{}, + &model.ChatAdminConversationState{}, + &model.ChatMessage{}, + ); err != nil { + t.Fatalf("迁移测试数据库失败: %v", err) + } + return db +} + +func TestListAdminConversationsUsesPersonalStateAndBatchData(t *testing.T) { + db := setupAdminListTestDB(t) + repo := NewRepository(db, nil, nil) + + admin := model.AdminUser{Username: "cs-1", Nickname: "客服一", Status: "active"} + user := model.User{Phone: "13800000001", Nickname: "租客"} + if err := db.Create(&admin).Error; err != nil { + t.Fatalf("创建管理员失败: %v", err) + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("创建用户失败: %v", err) + } + listing := model.RentalListing{ListingNo: "L202608250001", OwnerID: user.ID, AccountID: 1, Status: "active"} + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("创建商品失败: %v", err) + } + order := model.RentalOrder{OrderNo: "RO-CHAT-1", ListingID: listing.ID, AccountID: 1, OwnerID: user.ID, RenterID: user.ID, Status: "renting"} + if err := db.Create(&order).Error; err != nil { + t.Fatalf("创建订单失败: %v", err) + } + conversation := model.ChatConversation{OrderID: &order.ID, Type: ConversationTypeOrderGroup, Title: "订单群", Status: "active"} + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("创建会话失败: %v", err) + } + now := time.Now() + if err := db.Create(&[]model.ChatParticipant{ + {ConversationID: conversation.ID, ParticipantType: "user", ParticipantID: user.ID, Role: "renter", JoinedAt: now}, + {ConversationID: conversation.ID, ParticipantType: "admin", ParticipantID: admin.ID, Role: "support", JoinedAt: now}, + }).Error; err != nil { + t.Fatalf("创建参与人失败: %v", err) + } + system := model.ChatMessage{ConversationID: conversation.ID, SenderType: "system", SenderRole: "system", ContentType: "system", Content: "欢迎", CreatedAt: now} + userMessage := model.ChatMessage{ConversationID: conversation.ID, SenderType: "user", SenderID: user.ID, SenderRole: "renter", ContentType: "text", Content: "请问?", CreatedAt: now.Add(time.Second)} + if err := db.Create(&[]model.ChatMessage{system, userMessage}).Error; err != nil { + t.Fatalf("创建消息失败: %v", err) + } + if err := db.Model(&conversation).Updates(map[string]interface{}{ + "last_message_id": userMessage.ID, + "last_message_preview": userMessage.Content, + "last_message_at": userMessage.CreatedAt, + }).Error; err != nil { + t.Fatalf("更新会话摘要失败: %v", err) + } + if err := db.Create(&model.ChatAdminConversationState{ + ConversationID: conversation.ID, + AdminUserID: admin.ID, + Remark: "客户一", + LastReadMessageID: system.ID, + LastReadAt: &system.CreatedAt, + }).Error; err != nil { + t.Fatalf("创建管理员状态失败: %v", err) + } + + result, err := repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStageAll, "") + if err != nil { + t.Fatalf("查询后台会话失败: %v", err) + } + if result.Total != 1 || len(result.Items.([]ConversationDTO)) != 1 { + t.Fatalf("会话数量 = total %d/items %d, want 1/1", result.Total, len(result.Items.([]ConversationDTO))) + } + item := result.Items.([]ConversationDTO)[0] + if item.AdminRemark != "客户一" { + t.Fatalf("管理员备注 = %q, want 客户一", item.AdminRemark) + } + if item.UnreadCount != 1 { + t.Fatalf("管理员未读数 = %d, want 1", item.UnreadCount) + } + counts := result.Counts.(*AdminConversationCountsDTO) + if counts.Ownership[adminChatFilterMine] != 1 || counts.Stages[adminChatStageAll] != 1 { + t.Fatalf("聚合统计异常: ownership=%v stages=%v", counts.Ownership, counts.Stages) + } +} diff --git a/backend/internal/modules/chat/conversation.go b/backend/internal/modules/chat/conversation.go index 9568cd9..af2442d 100644 --- a/backend/internal/modules/chat/conversation.go +++ b/backend/internal/modules/chat/conversation.go @@ -53,6 +53,19 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal, if err != nil { return nil, err } + var state model.ChatAdminConversationState + if err := db.Where("conversation_id = ? AND admin_user_id = ?", conversation.ID, principal.ID).First(&state).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + 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.id > ?", state.LastReadMessageID). + Count(&unreadCount).Error; err != nil { + return nil, err + } dto := ConversationDTO{ ID: conversation.ID, OrderID: conversation.OrderID, @@ -62,11 +75,12 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal, Title: conversation.Title, Status: conversation.Status, Role: "admin", // 管理员角色 + AdminRemark: state.Remark, Participants: participants, LastMessageID: conversation.LastMessageID, LastMessagePreview: conversation.LastMessagePreview, LastMessageAt: conversation.LastMessageAt, - UnreadCount: 0, // 管理员不计未读 + UnreadCount: unreadCount, CreatedAt: conversation.CreatedAt, UpdatedAt: conversation.UpdatedAt, } diff --git a/backend/internal/modules/chat/conversation_test.go b/backend/internal/modules/chat/conversation_test.go index 854adb4..b47fea6 100644 --- a/backend/internal/modules/chat/conversation_test.go +++ b/backend/internal/modules/chat/conversation_test.go @@ -24,6 +24,7 @@ func setupConversationTestDB(t *testing.T) *gorm.DB { &model.RentalOrder{}, &model.ChatConversation{}, &model.ChatParticipant{}, + &model.ChatAdminConversationState{}, &model.ChatMessage{}, ); err != nil { t.Fatalf("数据库迁移失败: %v", err) diff --git a/backend/internal/modules/chat/dto.go b/backend/internal/modules/chat/dto.go index b5aa8d8..97595f3 100644 --- a/backend/internal/modules/chat/dto.go +++ b/backend/internal/modules/chat/dto.go @@ -21,6 +21,7 @@ type ConversationDTO struct { Title string `json:"title"` Status string `json:"status"` Role string `json:"role"` + AdminRemark string `json:"admin_remark,omitempty"` Participants []ParticipantDTO `json:"participants,omitempty"` LastMessageID *uint64 `json:"last_message_id"` LastMessagePreview string `json:"last_message_preview"` diff --git a/backend/internal/modules/chat/message.go b/backend/internal/modules/chat/message.go index 94ee54b..86aefbf 100644 --- a/backend/internal/modules/chat/message.go +++ b/backend/internal/modules/chat/message.go @@ -40,12 +40,15 @@ func (r *Repository) Messages(ctx context.Context, principal Principal, conversa offset := (page - 1) * pageSize var rows []model.ChatMessage - if err := query.Order("id ASC"). + if err := query.Order("id DESC"). Offset(offset). Limit(pageSize). Find(&rows).Error; err != nil { return nil, err } + for left, right := 0, len(rows)-1; left < right; left, right = left+1, right-1 { + rows[left], rows[right] = rows[right], rows[left] + } items, err := r.toMessageDTOs(ctx, principal, rows) if err != nil { return nil, err @@ -166,21 +169,44 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa now := time.Now() updated := false err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 管理员可以不是 participant,直接返回成功 if principal.Type == "admin" { - // 尝试查找 participant 记录,如果有就更新 - var participant model.ChatParticipant - err := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", - conversationID, principal.Type, principal.ID).First(&participant).Error - if err == nil { - // 有 participant 记录,更新已读时间 - updated = true - return tx.Model(&participant).Update("last_read_at", now).Error - } else if errors.Is(err, gorm.ErrRecordNotFound) { - // 没有 participant 记录,直接返回成功(管理员无需记录已读) + var conversation model.ChatConversation + if err := tx.Select("id", "last_message_id").First(&conversation, conversationID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrConversationNotFound + } + return err + } + lastMessageID := uint64(0) + if conversation.LastMessageID != nil { + lastMessageID = *conversation.LastMessageID + } + var existing model.ChatAdminConversationState + err := tx.Where("conversation_id = ? AND admin_user_id = ?", conversationID, principal.ID). + First(&existing).Error + if err == nil && existing.LastReadMessageID >= lastMessageID { return nil } - return err + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + state := model.ChatAdminConversationState{ + ConversationID: conversationID, + AdminUserID: principal.ID, + LastReadMessageID: lastMessageID, + LastReadAt: &now, + } + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "conversation_id"}, {Name: "admin_user_id"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "last_read_message_id": lastMessageID, + "last_read_at": now, + }), + }).Create(&state).Error; err != nil { + return err + } + updated = true + return nil } // 普通用户必须是 participant diff --git a/backend/internal/modules/chat/participant.go b/backend/internal/modules/chat/participant.go index 2267053..3f1b659 100644 --- a/backend/internal/modules/chat/participant.go +++ b/backend/internal/modules/chat/participant.go @@ -25,6 +25,7 @@ type conversationRow struct { Title string Status string Role string + AdminRemark string LastMessageID *uint64 LastMessagePreview string LastMessageAt *time.Time @@ -44,6 +45,7 @@ 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) ) AS unread_count`, principal.Type, principal.ID). @@ -59,6 +61,7 @@ func (r *Repository) CountUnreadMessages(ctx context.Context, principal Principa Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID). Where("c.status = ?", "active"). Where("NOT (cm.sender_type = ? AND cm.sender_id = ?)", principal.Type, principal.ID). + Where("cm.sender_type <> ?", "system"). Where("(cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)"). Count(&total).Error return total, err @@ -81,15 +84,29 @@ func (r *Repository) findParticipant(tx *gorm.DB, principal Principal, conversat return &participant, nil } func (r *Repository) participants(ctx context.Context, conversationID uint64) ([]ParticipantDTO, error) { + grouped, err := r.participantsForConversations(ctx, []uint64{conversationID}) + if err != nil { + return nil, err + } + return grouped[conversationID], nil +} + +func (r *Repository) participantsForConversations(ctx context.Context, conversationIDs []uint64) (map[uint64][]ParticipantDTO, error) { + result := make(map[uint64][]ParticipantDTO, len(conversationIDs)) + if len(conversationIDs) == 0 { + return result, nil + } var rows []model.ChatParticipant - if err := r.db.WithContext(ctx).Where("conversation_id = ?", conversationID).Order("id ASC").Find(&rows).Error; err != nil { + if err := r.db.WithContext(ctx). + Where("conversation_id IN ?", uniqueIDs(conversationIDs)). + Order("conversation_id ASC, id ASC"). + Find(&rows).Error; err != nil { return nil, err } userNames, userAvatars, adminNames, err := r.participantNames(ctx, rows) if err != nil { return nil, err } - items := make([]ParticipantDTO, 0, len(rows)) for _, row := range rows { name := "系统" avatar := "" @@ -100,19 +117,20 @@ func (r *Repository) participants(ctx context.Context, conversationID uint64) ([ if row.ParticipantType == "admin" { name = adminNames[row.ParticipantID] } - items = append(items, ParticipantDTO{ + result[row.ConversationID] = append(result[row.ConversationID], ParticipantDTO{ ID: row.ID, ConversationID: row.ConversationID, ParticipantType: row.ParticipantType, ParticipantID: row.ParticipantID, Role: row.Role, + Remark: row.Remark, DisplayName: fallbackName(row.ParticipantType, row.ParticipantID, name), AvatarURL: avatar, LastReadAt: row.LastReadAt, JoinedAt: row.JoinedAt, }) } - return items, nil + return result, nil } func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) { userIDs := make([]uint64, 0) @@ -305,6 +323,7 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO Title: row.Title, Status: row.Status, Role: row.Role, + AdminRemark: row.AdminRemark, Participants: participants, LastMessageID: row.LastMessageID, LastMessagePreview: row.LastMessagePreview, diff --git a/backend/internal/modules/chat/quick_reply.go b/backend/internal/modules/chat/quick_reply.go index de1abcb..d7bebd7 100644 --- a/backend/internal/modules/chat/quick_reply.go +++ b/backend/internal/modules/chat/quick_reply.go @@ -2,7 +2,12 @@ package chat import ( "context" + "errors" "hfb_sys/backend/internal/model" + "strings" + + "gorm.io/gorm" + "gorm.io/gorm/clause" ) const ( @@ -13,9 +18,26 @@ const ( ) func (r *Repository) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, remark string) error { - return r.db.WithContext(ctx).Model(&model.ChatParticipant{}). - Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, principal.Type, principal.ID). - Update("remark", remark).Error + if principal.Type != "admin" { + return ErrPermissionDenied + } + db := r.db.WithContext(ctx) + var conversation model.ChatConversation + if err := db.Select("id").First(&conversation, conversationID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrConversationNotFound + } + return err + } + state := model.ChatAdminConversationState{ + ConversationID: conversationID, + AdminUserID: principal.ID, + Remark: strings.TrimSpace(remark), + } + return db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "conversation_id"}, {Name: "admin_user_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"remark", "updated_at"}), + }).Create(&state).Error } func (r *Repository) ListQuickReplies(ctx context.Context, adminID uint64) ([]QuickReplyDTO, error) { var replies []model.ChatQuickReply diff --git a/backend/internal/modules/chat/service.go b/backend/internal/modules/chat/service.go index 4166b4e..a1930d2 100644 --- a/backend/internal/modules/chat/service.go +++ b/backend/internal/modules/chat/service.go @@ -159,6 +159,10 @@ func (s *Service) UpdateRemark(ctx context.Context, principal Principal, convers if s.repo == nil { return ErrDependencyUnavailable } + req.Remark = strings.TrimSpace(req.Remark) + if len([]rune(req.Remark)) > 128 { + return ErrInvalidMessage + } return s.repo.UpdateRemark(ctx, principal, conversationID, req.Remark) } diff --git a/backend/internal/modules/chat/support.go b/backend/internal/modules/chat/support.go index 9d5bd36..b587788 100644 --- a/backend/internal/modules/chat/support.go +++ b/backend/internal/modules/chat/support.go @@ -6,6 +6,7 @@ import ( "fmt" "gorm.io/gorm" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/chathub" "strings" "time" ) @@ -33,15 +34,17 @@ type AdminConversationCountsDTO struct { } 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 { - // 验证当前操作者是会话参与者 - current, err := r.findParticipant(tx, principal, conversationID, false) - if err != nil { - return err - } - if current.Role != "support" || current.ParticipantType != "admin" { + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if principal.Type != "admin" { return ErrPermissionDenied } + var conversation model.ChatConversation + if err := tx.First(&conversation, conversationID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrConversationNotFound + } + return err + } // 验证目标客服存在、活跃且拥有客服角色,避免转接给超级管理员。 if !adminIsSupport(tx, toAdminID) { return fmt.Errorf("目标客服不存在、已禁用或不是客服角色") @@ -56,29 +59,45 @@ func (r *Repository) TransferConversation(ctx context.Context, principal Princip if count > 0 { return fmt.Errorf("该客服已在会话中") } - // 只转接当前客服本人,避免误删同群里的收号组/卖号组其他客服。 - if err := tx.Model(&model.ChatParticipant{}). - Where("id = ?", current.ID). - Updates(map[string]interface{}{ + var current model.ChatParticipant + currentErr := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, "admin", principal.ID). + First(¤t).Error + if currentErr == nil { + if current.Role != "support" { + return ErrPermissionDenied + } + // 只转接当前客服本人,避免误删同群里的其他客服。 + if err := tx.Model(¤t).Updates(map[string]interface{}{ "participant_id": toAdminID, "joined_at": time.Now(), + "last_read_at": nil, }).Error; err != nil { - return err + return err + } + } else if errors.Is(currentErr, gorm.ErrRecordNotFound) { + // 未分配会话允许从“全部/未分配”直接指派给目标客服。 + if err := tx.Create(&model.ChatParticipant{ + ConversationID: conversationID, + ParticipantType: "admin", + ParticipantID: toAdminID, + Role: "support", + JoinedAt: time.Now(), + }).Error; err != nil { + return err + } + } else { + return currentErr } - // 添加系统消息记录转接 - message := model.ChatMessage{ - ConversationID: conversationID, - SenderType: "system", - SenderRole: "system", - ContentType: "system", - Content: "会话已转接给其他客服", - AttachmentURLS: emptyJSONList(), - } - if err := tx.Create(&message).Error; err != nil { + if err := sendSystemMessage(tx, conversationID, "会话已转接给其他客服"); err != nil { return err } return nil }) + if err == nil && r.hub != nil { + r.hub.NotifyConversation(conversationID, &chathub.ChatEvent{Type: "conversation_updated", ConversationID: conversationID}) + r.hub.NotifyAllAdmins(&chathub.ChatEvent{Type: "conversation_updated", ConversationID: conversationID}) + } + return err } func (r *Repository) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAdminDTO, error) { db := r.db.WithContext(ctx) @@ -202,11 +221,8 @@ 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) { - 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 { + counts, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword) + if err != nil { return nil, err } @@ -216,16 +232,21 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ Select(`c.id, c.order_id, c.listing_id, c.type, c.support_scene, 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, + COALESCE(cas.remark, '') AS admin_remark, + COALESCE(explicit_lo.id, listing_lo.id) AS latest_order_id, + COALESCE(explicit_lo.order_no, listing_lo.order_no) AS latest_order_no, + COALESCE(explicit_lo.status, listing_lo.status) AS latest_order_status, + COALESCE(explicit_lo.handoff_status, listing_lo.handoff_status) AS latest_handoff_status, + COALESCE(explicit_lo.refund_status, listing_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 cm.sender_type <> 'system' 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) + AND cm.id > COALESCE(cas.last_read_message_id, 0) + ) AS unread_count`, principal.Type, principal.ID) applyAdminChatOwnershipFilter(queryDB, filter, principal) applyAdminChatStageFilter(queryDB, stage, principal) if err := queryDB. @@ -236,33 +257,34 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ return nil, err } - items := make([]ConversationDTO, 0, len(rows)) + ids := make([]uint64, 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)) + ids = append(ids, row.ID) } - - counts, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword) + participantsByConversation, err := r.participantsForConversations(ctx, ids) if err != nil { return nil, err } - return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize, Counts: counts}, nil + items := make([]ConversationDTO, 0, len(rows)) + 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 } 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_admin_conversation_states AS cas ON cas.conversation_id = c.id AND cas.admin_user_id = ?", 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)") + Joins("LEFT JOIN rental_orders AS explicit_lo ON explicit_lo.id = c.order_id"). + Joins("LEFT JOIN (SELECT ro.* FROM rental_orders AS ro JOIN (SELECT listing_id, MAX(id) AS max_id FROM rental_orders GROUP BY listing_id) AS latest ON latest.max_id = ro.id) AS listing_lo ON listing_lo.listing_id = c.listing_id AND c.order_id IS NULL"). + Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(explicit_lo.listing_id, listing_lo.listing_id, c.listing_id)") if keyword != "" { + db = db.Joins("LEFT JOIN users AS renter ON renter.id = COALESCE(explicit_lo.renter_id, listing_lo.renter_id)"). + Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(explicit_lo.owner_id, listing_lo.owner_id, l.owner_id)") like := "%" + keyword + "%" - db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR lo.order_no LIKE ? OR l.listing_no LIKE ? + db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR COALESCE(explicit_lo.order_no, listing_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 @@ -272,32 +294,54 @@ func (r *Repository) adminConversationBase(ctx context.Context, principal Princi 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)), - } +type adminConversationCountsResult struct { + Total int64 + DTO *AdminConversationCountsDTO +} + +func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*adminConversationCountsResult, error) { + ownership := make(map[string]string, len(adminChatFilters)) + ownershipArgs := make(map[string][]interface{}, len(adminChatFilters)) 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 + ownership[item], ownershipArgs[item] = adminChatOwnershipCondition(item, principal) + } + stages := make(map[string]string, len(adminChatStages)) + stageArgs := make(map[string][]interface{}, len(adminChatStages)) + for _, item := range adminChatStages { + stages[item], stageArgs[item] = adminChatStageCondition(item, principal) + } + + parts := make([]string, 0, 1+len(adminChatFilters)+len(adminChatStages)) + args := make([]interface{}, 0) + addCount := func(alias, left, right string, leftArgs, rightArgs []interface{}) { + parts = append(parts, "SUM(CASE WHEN "+left+" AND "+right+" THEN 1 ELSE 0 END) AS "+alias) + args = append(args, leftArgs...) + args = append(args, rightArgs...) + } + addCount("total", ownership[filter], stages[stage], ownershipArgs[filter], stageArgs[stage]) + for _, item := range adminChatFilters { + addCount("ownership_"+item, ownership[item], stages[stage], ownershipArgs[item], stageArgs[stage]) } 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 + addCount("stage_"+item, ownership[filter], stages[item], ownershipArgs[filter], stageArgs[item]) } - return counts, nil + + type row struct { + Total int64 + OwnershipMine, OwnershipAll, OwnershipUnassigned int64 + StageAll, StagePending, StageUnjoined, StageHandoff, StageRenting, StageAfterSale, StageEnded int64 + } + var result row + if err := r.adminConversationBase(ctx, principal, keyword).Select(strings.Join(parts, ", "), args...).Scan(&result).Error; err != nil { + return nil, err + } + return &adminConversationCountsResult{ + Total: result.Total, + DTO: &AdminConversationCountsDTO{ + Ownership: map[string]int64{adminChatFilterMine: result.OwnershipMine, adminChatFilterAll: result.OwnershipAll, adminChatFilterUnassigned: result.OwnershipUnassigned}, + Stages: map[string]int64{adminChatStageAll: result.StageAll, adminChatStagePending: result.StagePending, adminChatStageUnjoined: result.StageUnjoined, adminChatStageHandoff: result.StageHandoff, adminChatStageRenting: result.StageRenting, adminChatStageAfterSale: result.StageAfterSale, adminChatStageEnded: result.StageEnded}, + }, + }, nil } func normalizeAdminChatFilter(filter string) string { @@ -319,52 +363,64 @@ func normalizeAdminChatStage(stage string) string { } 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) - } + condition, args := adminChatOwnershipCondition(filter, principal) + db.Where(condition, args...) } func applyAdminChatStageFilter(db *gorm.DB, stage string, principal Principal) { + condition, args := adminChatStageCondition(stage, principal) + db.Where(condition, args...) +} + +func adminChatOwnershipCondition(filter string, principal Principal) (string, []interface{}) { + switch filter { + case adminChatFilterAll: + return "1 = 1", nil + case adminChatFilterUnassigned: + return `NOT EXISTS ( + SELECT 1 FROM chat_participants AS cp_support + WHERE cp_support.conversation_id = c.id + AND cp_support.participant_type = 'admin' + AND cp_support.role = 'support' + )`, nil + case adminChatFilterMine: + fallthrough + default: + return "cp_me.id IS NOT NULL", nil + } +} + +func adminChatStageCondition(stage string, principal Principal) (string, []interface{}) { + latestID := "COALESCE(explicit_lo.id, listing_lo.id)" + latestStatus := "COALESCE(explicit_lo.status, listing_lo.status)" + latestRefundStatus := "COALESCE(explicit_lo.refund_status, listing_lo.refund_status)" 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) - ) + return `( + lm.sender_type = 'user' + OR 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) ) - )`, "user", principal.Type, principal.ID) + )`, []interface{}{principal.ID} case adminChatStageUnjoined: - db.Where("lo.id IS NULL") + return latestID + " IS NULL", nil case adminChatStageHandoff: - db.Where("lo.status = ?", "pending_handoff") + return latestStatus + " = 'pending_handoff'", nil case adminChatStageRenting: - db.Where("lo.status IN ?", []string{"renting", "overdue"}) + return latestStatus + " IN ('renting', 'overdue')", nil 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") + return "(" + latestStatus + " IN ('pending_checkout_confirm', 'pending_checkout_accept', 'checkout_disputing', 'abnormal') OR (" + latestRefundStatus + " IS NOT NULL AND " + latestRefundStatus + " <> 'none'))", nil case adminChatStageEnded: - db.Where("(c.status IN ? OR lo.status IN ?)", []string{"archived", "closed"}, []string{"completed", "cancelled", "closed"}) + return "(c.status IN ('archived', 'closed') OR " + latestStatus + " IN ('completed', 'cancelled', 'closed'))", nil case adminChatStageAll: - return + fallthrough + default: + return "1 = 1", nil } } diff --git a/backend/migrations/000056_chat_admin_state_and_indexes.sql b/backend/migrations/000056_chat_admin_state_and_indexes.sql new file mode 100644 index 0000000..41ae372 --- /dev/null +++ b/backend/migrations/000056_chat_admin_state_and_indexes.sql @@ -0,0 +1,67 @@ +-- +goose Up +-- +goose StatementBegin + +CREATE TABLE IF NOT EXISTS chat_admin_conversation_states ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + conversation_id BIGINT UNSIGNED NOT NULL COMMENT '会话ID', + admin_user_id BIGINT UNSIGNED NOT NULL COMMENT '管理员ID', + remark VARCHAR(128) NOT NULL DEFAULT '' COMMENT '管理员个人备注', + last_read_message_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后已读消息ID', + last_read_at DATETIME NULL COMMENT '最后已读时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_chat_admin_conversation_state (conversation_id, admin_user_id), + KEY idx_chat_admin_state_admin (admin_user_id, conversation_id), + KEY idx_chat_admin_state_read (admin_user_id, last_read_message_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='管理员会话个人状态'; + +INSERT INTO chat_admin_conversation_states ( + conversation_id, admin_user_id, remark, last_read_message_id, last_read_at +) +SELECT + cp.conversation_id, + cp.participant_id, + cp.remark, + COALESCE(( + SELECT MAX(cm.id) + FROM chat_messages cm + WHERE cm.conversation_id = cp.conversation_id + AND cp.last_read_at IS NOT NULL + AND cm.created_at <= cp.last_read_at + ), 0), + cp.last_read_at +FROM chat_participants cp +WHERE cp.participant_type = 'admin' + AND (cp.remark <> '' OR cp.last_read_at IS NOT NULL) +ON DUPLICATE KEY UPDATE + remark = VALUES(remark), + last_read_message_id = GREATEST(last_read_message_id, VALUES(last_read_message_id)), + last_read_at = CASE + WHEN last_read_at IS NULL THEN VALUES(last_read_at) + WHEN VALUES(last_read_at) IS NULL THEN last_read_at + ELSE GREATEST(last_read_at, VALUES(last_read_at)) + END; + +ALTER TABLE chat_conversations + ADD INDEX idx_chat_conversations_status_activity (status, last_message_at, id), + ADD INDEX idx_chat_conversations_type_scene_activity (type, support_scene, last_message_at, id); + +-- 后台客服列表需要按商品快速定位最新订单,避免为每个会话执行全表排序。 +ALTER TABLE rental_orders + ADD INDEX idx_rental_orders_listing_id_id (listing_id, id); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +ALTER TABLE rental_orders + DROP INDEX idx_rental_orders_listing_id_id; + +ALTER TABLE chat_conversations + DROP INDEX idx_chat_conversations_type_scene_activity, + DROP INDEX idx_chat_conversations_status_activity; + +DROP TABLE IF EXISTS chat_admin_conversation_states; + +-- +goose StatementEnd diff --git a/frontend/src/features/admin/views/AdminChatsView.vue b/frontend/src/features/admin/views/AdminChatsView.vue index f0db5e9..e660a6b 100644 --- a/frontend/src/features/admin/views/AdminChatsView.vue +++ b/frontend/src/features/admin/views/AdminChatsView.vue @@ -87,8 +87,7 @@ const activeMembers = computed(() => { const participants = active.value?.participants || [] return participants .map(item => { - const remark = getParticipantRemark() - const name = remark ? `${remark}(${item.display_name})` : item.display_name + const name = item.remark ? `${item.remark}(${item.display_name})` : item.display_name return `${roleLabel(item.role)}:${name}` }) .join(' / ') @@ -121,14 +120,6 @@ let orderLoadToken = 0 // 桌面通知 const desktopNotification = useDesktopNotification('admin') -function getParticipantRemark() { - if (!active.value) return '' - const myParticipant = active.value.participants?.find( - p => p.participant_type === 'admin' && p.participant_id === currentAdminId - ) - return myParticipant?.remark || '' -} - function handleSSEEvent(event: ChatEvent) { if (event.type === 'conversation_updated') { loadConversations(false) @@ -433,10 +424,7 @@ function handleTransferSuccess() { async function startEditRemark() { if (!active.value) return - const myParticipant = active.value.participants?.find( - p => p.participant_type === 'admin' && p.participant_id === currentAdminId - ) - remarkValue.value = myParticipant?.remark || active.value.title + remarkValue.value = active.value.admin_remark || active.value.title remarkEditing.value = true } @@ -483,10 +471,7 @@ function senderLabel(item: ChatMessage) { } function getConversationTitle(item: ChatConversation) { - const myParticipant = item.participants?.find( - p => p.participant_type === 'admin' && p.participant_id === currentAdminId - ) - return myParticipant?.remark || item.title + return item.admin_remark || item.title } function getSupportName(item: ChatConversation) { diff --git a/frontend/src/features/chats/api/chats.ts b/frontend/src/features/chats/api/chats.ts index c6df45e..e8de375 100644 --- a/frontend/src/features/chats/api/chats.ts +++ b/frontend/src/features/chats/api/chats.ts @@ -30,6 +30,7 @@ export interface ChatConversation { title: string status: string role: 'renter' | 'owner' | 'support' | 'customer' | 'admin' + admin_remark?: string participants?: ChatParticipant[] last_message_id?: number last_message_preview: string