package chat import ( "context" "errors" "fmt" "gorm.io/gorm" "gorm.io/gorm/clause" "hfb_sys/backend/internal/model" "strconv" "time" ) 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 (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) CountUnreadMessages(ctx context.Context, principal Principal) (int64, error) { var total int64 err := r.db.WithContext(ctx).Table("chat_messages AS cm"). Joins("JOIN chat_participants AS cp ON cp.conversation_id = cm.conversation_id"). Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID). Where("NOT (cm.sender_type = ? AND cm.sender_id = ?)", principal.Type, principal.ID). Where("(cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)"). Count(&total).Error return total, err } 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 } 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 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 }