perf(chat): 优化后台客服会话查询与状态
This commit is contained in:
@@ -63,6 +63,7 @@ func MigrateRentalTransactionTestSchema(db *gorm.DB) error {
|
||||
&model.AuditLog{},
|
||||
&model.ChatConversation{},
|
||||
&model.ChatParticipant{},
|
||||
&model.ChatAdminConversationState{},
|
||||
&model.ChatMessage{},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user