package chat import ( "context" "encoding/json" "errors" "fmt" "strconv" "strings" "time" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/chathub" "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Repository struct { db *gorm.DB hub *chathub.Hub } const ( defaultSupportRoleCode = "cs" ) func NewRepository(db *gorm.DB, hub *chathub.Hub) *Repository { return &Repository{db: db, hub: hub} } func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatConversation, error) { var existing model.ChatConversation err := tx.Where("order_id = ?", order.ID).First(&existing).Error if err == nil { return &existing, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } now := time.Now() conversation := model.ChatConversation{ OrderID: &order.ID, Type: "order_group", Title: orderConversationTitle(order), Status: "active", } if err := tx.Create(&conversation).Error; err != nil { return nil, err } participants := []model.ChatParticipant{ { ConversationID: conversation.ID, ParticipantType: "user", ParticipantID: order.RenterID, Role: "renter", JoinedAt: now, }, { ConversationID: conversation.ID, ParticipantType: "user", ParticipantID: order.OwnerID, Role: "owner", JoinedAt: now, }, } if supportID := defaultSupportAdminID(tx); supportID > 0 { participants = append(participants, model.ChatParticipant{ ConversationID: conversation.ID, ParticipantType: "admin", ParticipantID: supportID, Role: "support", JoinedAt: now, }) } for _, participant := range participants { if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&participant).Error; err != nil { return nil, err } } // 获取自动话术 autoMessage := "订单已支付,群聊已创建。租客、号主和客服可在这里沟通交接与结账问题。" var cfg model.SystemConfig if err := tx.Where("`key` = ?", "chat.auto_welcome_message").First(&cfg).Error; err == nil && cfg.Value != "" { autoMessage = cfg.Value } message := model.ChatMessage{ ConversationID: conversation.ID, SenderType: "system", SenderRole: "system", ContentType: "system", Content: autoMessage, AttachmentURLS: emptyJSONList(), } if err := tx.Create(&message).Error; err != nil { return nil, err } conversation.LastMessageID = &message.ID conversation.LastMessagePreview = truncatePreview(message.Content) conversation.LastMessageAt = &message.CreatedAt if err := tx.Save(&conversation).Error; err != nil { return nil, err } return &conversation, nil } // NotifyNewConversation 推送群聊创建事件给会话参与者。应在事务提交后调用。 func (r *Repository) NotifyNewConversation(conversationID uint64) { if r.hub == nil { return } r.hub.NotifyConversation(conversationID, &chathub.ChatEvent{ Type: "conversation_updated", ConversationID: conversationID, }) } func (r *Repository) ListConversations(ctx context.Context, principal Principal, page, pageSize int) (*PaginatedResult, error) { page, pageSize = normalizePagination(page, pageSize) db := r.db.WithContext(ctx) var total int64 countDB := db.Table("chat_conversations AS c"). Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id"). Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) if err := countDB.Count(&total).Error; err != nil { return nil, err } var rows []conversationRow offset := (page - 1) * pageSize err := r.conversationQuery(ctx, principal). Order("COALESCE(c.last_message_at, c.created_at) DESC, c.id DESC"). Offset(offset). Limit(pageSize). Scan(&rows).Error if err != nil { return nil, err } items := make([]ConversationDTO, 0, len(rows)) for _, row := range rows { items = append(items, row.toDTO(nil)) } return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } func (r *Repository) FindConversation(ctx context.Context, principal Principal, id uint64) (*ConversationDTO, error) { db := r.db.WithContext(ctx) // 管理员可以查看任意会话,无需是 participant if principal.Type == "admin" { var conversation model.ChatConversation if err := db.First(&conversation, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrConversationNotFound } return nil, err } participants, err := r.participants(ctx, conversation.ID) if err != nil { return nil, err } dto := ConversationDTO{ ID: conversation.ID, OrderID: conversation.OrderID, Type: conversation.Type, Title: conversation.Title, Status: conversation.Status, Role: "admin", // 管理员角色 Participants: participants, LastMessageID: conversation.LastMessageID, LastMessagePreview: conversation.LastMessagePreview, LastMessageAt: conversation.LastMessageAt, UnreadCount: 0, // 管理员不计未读 CreatedAt: conversation.CreatedAt, UpdatedAt: conversation.UpdatedAt, } return &dto, nil } // 普通用户需要是 participant var row conversationRow err := r.conversationQuery(ctx, principal).Where("c.id = ?", id).First(&row).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrConversationNotFound } return nil, err } participants, err := r.participants(ctx, row.ID) if err != nil { return nil, err } dto := row.toDTO(participants) return &dto, nil } func (r *Repository) FindOrderConversation(ctx context.Context, userID uint64, orderID uint64) (*ConversationDTO, error) { var row conversationRow principal := Principal{Type: "user", ID: userID} err := r.conversationQuery(ctx, principal).Where("c.order_id = ?", orderID).First(&row).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrConversationNotFound } return nil, err } participants, err := r.participants(ctx, row.ID) if err != nil { return nil, err } dto := row.toDTO(participants) return &dto, nil } func (r *Repository) EnsureSupportConversation(ctx context.Context, userID uint64) (*ConversationDTO, error) { var conversationID uint64 err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var existing model.ChatConversation err := tx.Table("chat_conversations AS c"). Select("c.*"). Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id"). Where("c.type = ? AND cp.participant_type = ? AND cp.participant_id = ?", "general_support", "user", userID). Order("c.id ASC"). Limit(1). Find(&existing).Error if err != nil { return err } if existing.ID > 0 { conversationID = existing.ID return nil } now := time.Now() conversation := model.ChatConversation{ Type: "general_support", Title: "平台客服", Status: "active", } if err := tx.Create(&conversation).Error; err != nil { return err } participants := []model.ChatParticipant{ { ConversationID: conversation.ID, ParticipantType: "user", ParticipantID: userID, Role: "customer", JoinedAt: now, }, } if supportID := defaultSupportAdminID(tx); supportID > 0 { participants = append(participants, model.ChatParticipant{ ConversationID: conversation.ID, ParticipantType: "admin", ParticipantID: supportID, Role: "support", JoinedAt: now, }) } for _, participant := range participants { if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&participant).Error; err != nil { return err } } message := model.ChatMessage{ ConversationID: conversation.ID, SenderType: "system", SenderRole: "system", ContentType: "system", Content: "您好,客服会尽快回复,请直接描述您遇到的问题。", AttachmentURLS: emptyJSONList(), } if err := tx.Create(&message).Error; err != nil { return err } conversation.LastMessageID = &message.ID conversation.LastMessagePreview = truncatePreview(message.Content) conversation.LastMessageAt = &message.CreatedAt if err := tx.Save(&conversation).Error; err != nil { return err } conversationID = conversation.ID return nil }) if err != nil { return nil, err } item, err := r.FindConversation(ctx, Principal{Type: "user", ID: userID}, conversationID) if err != nil { return nil, err } r.NotifyNewConversation(conversationID) return item, nil } func (r *Repository) Messages(ctx context.Context, principal Principal, conversationID uint64, page, pageSize int) (*PaginatedResult, error) { page, pageSize = normalizePagination(page, pageSize) db := r.db.WithContext(ctx) // 管理员可以查看任意会话的消息,普通用户需要是 participant if principal.Type != "admin" { if _, err := r.findParticipant(db, principal, conversationID, false); err != nil { return nil, err } } else { // 管理员需要验证会话存在 var count int64 if err := db.Model(&model.ChatConversation{}).Where("id = ?", conversationID).Count(&count).Error; err != nil { return nil, err } if count == 0 { return nil, ErrConversationNotFound } } var total int64 if err := db.Model(&model.ChatMessage{}).Where("conversation_id = ?", conversationID).Count(&total).Error; err != nil { return nil, err } offset := (page - 1) * pageSize var rows []model.ChatMessage if err := db.Where("conversation_id = ?", conversationID). Order("id ASC"). Offset(offset). Limit(pageSize). Find(&rows).Error; err != nil { return nil, err } items, err := r.toMessageDTOs(ctx, principal, rows) if err != nil { return nil, err } return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } func (r *Repository) SendMessage(ctx context.Context, principal Principal, conversationID uint64, req SendMessageRequest) (*MessageDTO, error) { var messageID uint64 err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var conversation model.ChatConversation if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&conversation, conversationID).Error; err != nil { return err } if conversation.Status != "active" { return ErrPermissionDenied } var senderRole string var participant *model.ChatParticipant // 管理员可以在任意会话发送消息,无需是 participant if principal.Type == "admin" { // 尝试查找管理员的 participant 记录 var p model.ChatParticipant err := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, principal.Type, principal.ID).First(&p).Error if err == nil { // 管理员是 participant,使用其角色 participant = &p senderRole = p.Role } else if errors.Is(err, gorm.ErrRecordNotFound) { // 管理员不是 participant,使用特殊角色 "admin" senderRole = "admin" } else { return err } } else { // 普通用户必须是 participant p, err := r.findParticipant(tx, principal, conversationID, true) if err != nil { return err } participant = p senderRole = p.Role } message := model.ChatMessage{ ConversationID: conversation.ID, SenderType: principal.Type, SenderID: principal.ID, SenderRole: senderRole, ContentType: "text", Content: req.Content, AttachmentURLS: encodeStringList(req.AttachmentURLS), } if err := tx.Create(&message).Error; err != nil { return err } conversation.LastMessageID = &message.ID conversation.LastMessagePreview = messagePreview(message.Content, req.AttachmentURLS) conversation.LastMessageAt = &message.CreatedAt if err := tx.Save(&conversation).Error; err != nil { return err } // 更新 participant 的已读时间(仅当是 participant 时) if participant != nil { now := time.Now() if err := tx.Model(participant).Update("last_read_at", now).Error; err != nil { return err } } messageID = message.ID return nil }) if err != nil { return nil, err } var message model.ChatMessage if err := r.db.WithContext(ctx).First(&message, messageID).Error; err != nil { return nil, err } items, err := r.toMessageDTOs(ctx, principal, []model.ChatMessage{message}) if err != nil { return nil, err } if len(items) == 0 { return nil, ErrConversationNotFound } // 推送新消息事件给会话中的在线参与者 if r.hub != nil { msg := items[0] r.hub.NotifyConversation(conversationID, &chathub.ChatEvent{ 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), }, }) } return &items[0], nil } func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversationID uint64) error { return 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 记录,更新已读时间 now := time.Now() return tx.Model(&participant).Update("last_read_at", now).Error } else if errors.Is(err, gorm.ErrRecordNotFound) { // 没有 participant 记录,直接返回成功(管理员无需记录已读) return nil } return err } // 普通用户必须是 participant participant, err := r.findParticipant(tx, principal, conversationID, true) if err != nil { return err } now := time.Now() return tx.Model(participant).Update("last_read_at", now).Error }) } func (r *Repository) conversationQuery(ctx context.Context, principal Principal) *gorm.DB { return r.db.WithContext(ctx).Table("chat_conversations AS c"). Select(`c.id, c.order_id, c.type, c.title, c.status, c.last_message_id, c.last_message_preview, c.last_message_at, c.created_at, c.updated_at, cp.role, ( SELECT COUNT(1) FROM chat_messages AS cm WHERE cm.conversation_id = c.id AND NOT (cm.sender_type = ? AND cm.sender_id = ?) AND (cp.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) } func (r *Repository) findParticipant(tx *gorm.DB, principal Principal, conversationID uint64, lock bool) (*model.ChatParticipant, error) { var participant model.ChatParticipant db := tx if lock { db = db.Clauses(clause.Locking{Strength: "UPDATE"}) } err := db.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, principal.Type, principal.ID). First(&participant).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrPermissionDenied } return nil, err } return &participant, nil } func (r *Repository) participants(ctx context.Context, conversationID uint64) ([]ParticipantDTO, error) { var rows []model.ChatParticipant if err := r.db.WithContext(ctx).Where("conversation_id = ?", conversationID).Order("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 := "" if row.ParticipantType == "user" { name = userNames[row.ParticipantID] avatar = userAvatars[row.ParticipantID] } if row.ParticipantType == "admin" { name = adminNames[row.ParticipantID] } items = append(items, ParticipantDTO{ ID: row.ID, ConversationID: row.ConversationID, ParticipantType: row.ParticipantType, ParticipantID: row.ParticipantID, Role: row.Role, DisplayName: fallbackName(row.ParticipantType, row.ParticipantID, name), AvatarURL: avatar, LastReadAt: row.LastReadAt, JoinedAt: row.JoinedAt, }) } return items, nil } func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) { userIDs := make([]uint64, 0) adminIDs := make([]uint64, 0) for _, row := range rows { if row.SenderType == "user" && row.SenderID > 0 { userIDs = append(userIDs, row.SenderID) } if row.SenderType == "admin" && row.SenderID > 0 { adminIDs = append(adminIDs, row.SenderID) } } userNames, userAvatars, err := r.userNames(ctx, userIDs) if err != nil { return nil, err } adminNames, err := r.adminNames(ctx, adminIDs) if err != nil { return nil, err } items := make([]MessageDTO, 0, len(rows)) for _, row := range rows { name := "系统" avatar := "" if row.SenderType == "user" { name = userNames[row.SenderID] avatar = userAvatars[row.SenderID] } if row.SenderType == "admin" { name = adminNames[row.SenderID] // 如果角色是 "admin"(不是 participant 的管理员),在名字后添加标识 if row.SenderRole == "admin" { name = name + " (管理员)" } } items = append(items, MessageDTO{ ID: row.ID, ConversationID: row.ConversationID, SenderType: row.SenderType, SenderID: row.SenderID, SenderRole: row.SenderRole, SenderName: fallbackName(row.SenderType, row.SenderID, name), SenderAvatar: avatar, IsSelf: row.SenderType == principal.Type && row.SenderID == principal.ID, ContentType: row.ContentType, Content: row.Content, AttachmentURLS: decodeStringList(row.AttachmentURLS), CreatedAt: row.CreatedAt, }) } return items, nil } func (r *Repository) participantNames(ctx context.Context, rows []model.ChatParticipant) (map[uint64]string, map[uint64]string, map[uint64]string, error) { userIDs := make([]uint64, 0) adminIDs := make([]uint64, 0) for _, row := range rows { if row.ParticipantType == "user" { userIDs = append(userIDs, row.ParticipantID) } if row.ParticipantType == "admin" { adminIDs = append(adminIDs, row.ParticipantID) } } userNames, userAvatars, err := r.userNames(ctx, userIDs) if err != nil { return nil, nil, nil, err } adminNames, err := r.adminNames(ctx, adminIDs) if err != nil { return nil, nil, nil, err } return userNames, userAvatars, adminNames, nil } func (r *Repository) userNames(ctx context.Context, ids []uint64) (map[uint64]string, map[uint64]string, error) { names := map[uint64]string{} avatars := map[uint64]string{} if len(ids) == 0 { return names, avatars, nil } var users []model.User if err := r.db.WithContext(ctx).Where("id IN ?", uniqueIDs(ids)).Find(&users).Error; err != nil { return nil, nil, err } for _, user := range users { name := user.Nickname if name == "" { name = user.Phone } names[user.ID] = name avatars[user.ID] = user.AvatarURL } return names, avatars, nil } func (r *Repository) adminNames(ctx context.Context, ids []uint64) (map[uint64]string, error) { names := map[uint64]string{} if len(ids) == 0 { return names, nil } var admins []model.AdminUser if err := r.db.WithContext(ctx).Where("id IN ?", uniqueIDs(ids)).Find(&admins).Error; err != nil { return nil, err } for _, admin := range admins { name := admin.Nickname if name == "" { name = admin.Username } names[admin.ID] = name } return names, nil } type conversationRow struct { ID uint64 OrderID *uint64 Type string Title string Status string Role string LastMessageID *uint64 LastMessagePreview string LastMessageAt *time.Time UnreadCount int64 CreatedAt time.Time UpdatedAt time.Time } func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO { return ConversationDTO{ ID: row.ID, OrderID: row.OrderID, Type: row.Type, Title: row.Title, Status: row.Status, Role: row.Role, Participants: participants, LastMessageID: row.LastMessageID, LastMessagePreview: row.LastMessagePreview, LastMessageAt: row.LastMessageAt, UnreadCount: row.UnreadCount, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } } func defaultSupportAdminID(tx *gorm.DB) uint64 { if supportID := configuredDefaultSupportAdminID(tx); supportID > 0 { return supportID } adminIDs, err := supportAdminIDs(tx) if err != nil || len(adminIDs) == 0 { return 0 } // 未配置默认客服时,按当前会话负载选择客服角色中最空闲的一位。 type adminLoad struct { AdminID uint64 Count int64 } var loads []adminLoad tx.Table("chat_participants AS cp"). Select("cp.participant_id AS admin_id, COUNT(*) AS count"). Where("cp.participant_type = ? AND cp.role = ? AND cp.participant_id IN ?", "admin", "support", adminIDs). Group("cp.participant_id"). Scan(&loads) loadMap := make(map[uint64]int64) for _, l := range loads { loadMap[l.AdminID] = l.Count } // 找到负载最少的客服 var minLoad int64 = -1 var selectedID uint64 for _, id := range adminIDs { count := loadMap[id] if minLoad < 0 || count < minLoad { minLoad = count selectedID = id } } if selectedID > 0 { return selectedID } return 0 } func configuredDefaultSupportAdminID(tx *gorm.DB) uint64 { var cfg model.SystemConfig if err := tx.Where("`key` = ?", "chat.default_support_admin_id").First(&cfg).Error; err == nil { id, parseErr := strconv.ParseUint(cfg.Value, 10, 64) if parseErr == nil && id > 0 && adminIsSupport(tx, id) { return id } } return 0 } func supportAdminIDs(tx *gorm.DB) ([]uint64, error) { var adminIDs []uint64 err := tx.Table("admin_users AS au"). Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id"). Joins("JOIN roles AS r ON r.id = aur.role_id"). Where("au.status = ? AND r.code = ?", "active", defaultSupportRoleCode). Order("CASE au.support_status WHEN 'online' THEN 0 WHEN 'busy' THEN 1 ELSE 2 END, au.id ASC"). Pluck("au.id", &adminIDs).Error return adminIDs, err } func adminIsSupport(tx *gorm.DB, id uint64) bool { var count int64 if err := tx.Table("admin_users AS au"). Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id"). Joins("JOIN roles AS r ON r.id = aur.role_id"). Where("au.id = ? AND au.status = ? AND r.code = ?", id, "active", defaultSupportRoleCode). Count(&count).Error; err != nil { return false } return count > 0 } func orderConversationTitle(order model.RentalOrder) string { if order.OrderNo == "" { return fmt.Sprintf("订单群聊 #%d", order.ID) } return "订单群聊 " + order.OrderNo } func normalizePagination(page, pageSize int) (int, int) { if page < 1 { page = 1 } if pageSize < 1 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } return page, pageSize } func truncatePreview(content string) string { runes := []rune(content) if len(runes) <= 80 { return content } return string(runes[:80]) } func messagePreview(content string, attachments []string) string { content = strings.TrimSpace(content) if content != "" { return truncatePreview(content) } if len(attachments) > 0 { return "[图片]" } return "" } // TransferConversation 转接会话给其他客服 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 { // 验证当前操作者是会话参与者 if _, err := r.findParticipant(tx, principal, conversationID, false); err != nil { return err } // 验证目标客服存在、活跃且拥有客服角色,避免转接给超级管理员。 if !adminIsSupport(tx, toAdminID) { return fmt.Errorf("目标客服不存在、已禁用或不是客服角色") } // 检查目标客服是否已有该会话 var count int64 if err := tx.Model(&model.ChatParticipant{}). Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, "admin", toAdminID). Count(&count).Error; err != nil { return err } if count > 0 { return fmt.Errorf("该客服已在会话中") } // 删除原客服参与者 if err := tx.Where("conversation_id = ? AND participant_type = ? AND role = ?", conversationID, "admin", "support"). Delete(&model.ChatParticipant{}).Error; err != nil { return err } // 添加新客服参与者 participant := model.ChatParticipant{ ConversationID: conversationID, ParticipantType: "admin", ParticipantID: toAdminID, Role: "support", JoinedAt: time.Now(), } if err := tx.Create(&participant).Error; err != nil { return err } // 添加系统消息记录转接 message := model.ChatMessage{ ConversationID: conversationID, SenderType: "system", SenderRole: "system", ContentType: "system", Content: "会话已转接给其他客服", AttachmentURLS: emptyJSONList(), } if err := tx.Create(&message).Error; err != nil { return err } return nil }) } // GetAvailableSupportAdmins 获取可用客服列表及其会话数 func (r *Repository) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAdminDTO, error) { db := r.db.WithContext(ctx) // 仅展示客服角色管理员,超级管理员即使有 chat:view 权限也不作为客服候选。 type adminRow struct { ID uint64 Nickname string SupportStatus string } var admins []adminRow err := db.Table("admin_users AS au"). Select("au.id, COALESCE(NULLIF(au.nickname, ''), au.username) AS nickname, au.support_status"). Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id"). Joins("JOIN roles AS r ON r.id = aur.role_id"). Where("au.status = ? AND r.code = ?", "active", defaultSupportRoleCode). Order("CASE au.support_status WHEN 'online' THEN 0 WHEN 'busy' THEN 1 ELSE 2 END, au.id ASC"). Scan(&admins).Error if err != nil { return nil, err } // 统计每个客服的会话数 type loadRow struct { AdminID uint64 Count int64 } var loads []loadRow adminIDs := make([]uint64, len(admins)) for i, a := range admins { adminIDs[i] = a.ID } if len(adminIDs) > 0 { db.Table("chat_participants"). Select("participant_id AS admin_id, COUNT(*) AS count"). Where("participant_type = ? AND role = ? AND participant_id IN ?", "admin", "support", adminIDs). Group("participant_id"). Scan(&loads) } loadMap := make(map[uint64]int64) for _, l := range loads { loadMap[l.AdminID] = l.Count } result := make([]SupportAdminDTO, len(admins)) for i, a := range admins { result[i] = SupportAdminDTO{ ID: a.ID, Nickname: a.Nickname, SupportStatus: a.SupportStatus, ChatCount: loadMap[a.ID], } } return result, nil } // ListConversationsWithFilter 支持筛选的会话列表 func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal Principal, page, pageSize int, filter string) (*PaginatedResult, error) { page, pageSize = normalizePagination(page, pageSize) db := r.db.WithContext(ctx) // 管理员在"全部"模式下直接查询所有会话 if principal.Type == "admin" && filter == "all" { var total int64 if err := db.Model(&model.ChatConversation{}).Count(&total).Error; err != nil { return nil, err } var conversations []model.ChatConversation offset := (page - 1) * pageSize if err := db.Order("COALESCE(last_message_at, created_at) DESC, id DESC"). Offset(offset). Limit(pageSize). Find(&conversations).Error; err != nil { return nil, err } items := make([]ConversationDTO, 0, len(conversations)) for _, conv := range conversations { participants, err := r.participants(ctx, conv.ID) if err != nil { return nil, err } items = append(items, ConversationDTO{ ID: conv.ID, OrderID: conv.OrderID, Type: conv.Type, Title: conv.Title, Status: conv.Status, Role: "admin", // 管理员角色 Participants: participants, LastMessageID: conv.LastMessageID, LastMessagePreview: conv.LastMessagePreview, LastMessageAt: conv.LastMessageAt, UnreadCount: 0, // 管理员不计未读 CreatedAt: conv.CreatedAt, UpdatedAt: conv.UpdatedAt, }) } return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } // 其他情况使用原有逻辑 var total int64 countDB := db.Table("chat_conversations AS c"). Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id") switch filter { case "mine": // 只看我的会话 countDB = countDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) case "unassigned": // 未分配客服的会话 countDB = countDB.Where("c.id NOT IN (?)", db.Table("chat_participants").Select("conversation_id").Where("participant_type = ? AND role = ?", "admin", "support")) default: // 普通用户的全部会话 countDB = countDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) } if err := countDB.Count(&total).Error; err != nil { return nil, err } var rows []conversationRow offset := (page - 1) * pageSize queryDB := r.conversationQuery(ctx, principal) switch filter { case "mine": queryDB = queryDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) case "unassigned": queryDB = queryDB.Where("c.id NOT IN (?)", db.Table("chat_participants").Select("conversation_id").Where("participant_type = ? AND role = ?", "admin", "support")) default: // 普通用户的全部会话 queryDB = queryDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) } err := queryDB. Order("COALESCE(c.last_message_at, c.created_at) DESC, c.id DESC"). Offset(offset). Limit(pageSize). Scan(&rows).Error if err != nil { return nil, err } items := make([]ConversationDTO, 0, len(rows)) for _, row := range rows { participants, err := r.participants(ctx, row.ID) if err != nil { return nil, err } items = append(items, row.toDTO(participants)) } return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } func fallbackName(participantType string, id uint64, name string) string { if name != "" { return name } switch participantType { case "admin": return "客服" case "system": return "系统" default: return fmt.Sprintf("用户%d", id) } } func uniqueIDs(ids []uint64) []uint64 { seen := map[uint64]bool{} result := make([]uint64, 0, len(ids)) for _, id := range ids { if id == 0 || seen[id] { continue } seen[id] = true result = append(result, id) } return result } func emptyJSONList() datatypes.JSON { raw, _ := json.Marshal([]string{}) return datatypes.JSON(raw) } func encodeStringList(items []string) datatypes.JSON { raw, _ := json.Marshal(items) return datatypes.JSON(raw) } // UpdateRemark 更新会话备注 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 } // ListQuickReplies 获取快捷回复列表(个人 + 全局) func (r *Repository) ListQuickReplies(ctx context.Context, adminID uint64) ([]QuickReplyDTO, error) { var replies []model.ChatQuickReply err := r.db.WithContext(ctx).Where("admin_user_id = ? OR admin_user_id = 0", adminID). Order("admin_user_id DESC, sort_order ASC, id ASC"). Find(&replies).Error if err != nil { return nil, err } result := make([]QuickReplyDTO, len(replies)) for i, reply := range replies { result[i] = QuickReplyDTO{ ID: reply.ID, AdminUserID: reply.AdminUserID, Title: reply.Title, Content: reply.Content, SortOrder: reply.SortOrder, IsGlobal: reply.AdminUserID == 0, } } return result, nil } // CreateQuickReply 创建快捷回复 func (r *Repository) CreateQuickReply(ctx context.Context, adminID uint64, req CreateQuickReplyRequest) (*QuickReplyDTO, error) { ownerID := adminID if req.IsGlobal { ownerID = 0 } reply := model.ChatQuickReply{ AdminUserID: ownerID, Title: req.Title, Content: req.Content, SortOrder: req.SortOrder, } if err := r.db.WithContext(ctx).Create(&reply).Error; err != nil { return nil, err } return &QuickReplyDTO{ ID: reply.ID, AdminUserID: reply.AdminUserID, Title: reply.Title, Content: reply.Content, SortOrder: reply.SortOrder, IsGlobal: reply.AdminUserID == 0, }, nil } // UpdateQuickReply 更新快捷回复 func (r *Repository) UpdateQuickReply(ctx context.Context, adminID uint64, replyID uint64, req UpdateQuickReplyRequest) error { query := r.db.WithContext(ctx).Model(&model.ChatQuickReply{}).Where("id = ? AND (admin_user_id = ? OR admin_user_id = 0)", replyID, adminID) updates := map[string]interface{}{} if req.Title != "" { updates["title"] = req.Title } if req.Content != "" { updates["content"] = req.Content } if req.SortOrder != nil { updates["sort_order"] = *req.SortOrder } if len(updates) == 0 { return nil } return query.Updates(updates).Error } // DeleteQuickReply 删除快捷回复 func (r *Repository) DeleteQuickReply(ctx context.Context, adminID uint64, replyID uint64) error { return r.db.WithContext(ctx).Where("id = ? AND admin_user_id = ?", replyID, adminID). Delete(&model.ChatQuickReply{}).Error } // GetAutoWelcomeMessage 获取建群自动话术 func (r *Repository) GetAutoWelcomeMessage(ctx context.Context) string { var cfg model.SystemConfig if err := r.db.WithContext(ctx).Where("`key` = ?", "chat.auto_welcome_message").First(&cfg).Error; err != nil { return "欢迎加入订单群聊!如有任何问题,请随时沟通。" } return cfg.Value } // UpdateAutoWelcomeMessage 更新建群自动话术 func (r *Repository) UpdateAutoWelcomeMessage(ctx context.Context, message string) error { return r.db.WithContext(ctx).Model(&model.SystemConfig{}). Where("`key` = ?", "chat.auto_welcome_message"). Update("value", message).Error } func decodeStringList(raw datatypes.JSON) []string { if len(raw) == 0 { return []string{} } var items []string if err := json.Unmarshal(raw, &items); err != nil { return []string{} } return items }