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 ListingID *uint64 LatestOrderID *uint64 LatestOrderNo string LatestOrderStatus string LatestHandoffStatus string LatestRefundStatus string Type string SupportScene string Title string Status string Role string AdminRemark string LastMessageID *uint64 LastMessagePreview string LastMessageAt *time.Time LastSenderType string LastSenderID uint64 LastSenderRole string 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.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, cp.role, ( 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). 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"). Joins("JOIN chat_conversations AS c ON c.id = cm.conversation_id"). 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 } 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) { 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 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 } 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] } 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 result, nil } func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) { userIDs := make([]uint64, 0) adminIDs := make([]uint64, 0) conversationIDSet := make(map[uint64]struct{}) 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) } conversationIDSet[row.ConversationID] = struct{}{} } 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 } // 加载相关会话的参与者已读时间,用于推导「自己发出的消息」是否已被对方读取。 readParticipants, err := r.conversationReadParticipants(ctx, conversationIDSet) 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 + " (管理员)" } } isSelf := row.SenderType == principal.Type && row.SenderID == principal.ID 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: isSelf, // 仅对自己发出的消息计算已读:是否已被所有其他参与者读取。 IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row), ContentType: row.ContentType, Content: row.Content, AttachmentURLS: decodeStringList(row.AttachmentURLS), CreatedAt: row.CreatedAt, }) } return items, nil } type readParticipant struct { ParticipantType string ParticipantID uint64 LastReadAt *time.Time } // conversationReadParticipants 批量加载若干会话的参与者已读时间。 func (r *Repository) conversationReadParticipants(ctx context.Context, ids map[uint64]struct{}) (map[uint64][]readParticipant, error) { result := make(map[uint64][]readParticipant) if len(ids) == 0 { return result, nil } idList := make([]uint64, 0, len(ids)) for id := range ids { idList = append(idList, id) } type row struct { ConversationID uint64 ParticipantType string ParticipantID uint64 LastReadAt *time.Time } var rows []row if err := r.db.WithContext(ctx).Table("chat_participants"). Select("conversation_id, participant_type, participant_id, last_read_at"). Where("conversation_id IN ?", idList). Find(&rows).Error; err != nil { return nil, err } for _, rw := range rows { result[rw.ConversationID] = append(result[rw.ConversationID], readParticipant{ ParticipantType: rw.ParticipantType, ParticipantID: rw.ParticipantID, LastReadAt: rw.LastReadAt, }) } return result, nil } // messageReadByOthers 采用「全部已读」语义:所有其他参与者的 last_read_at 均不早于该消息时间。 // 1对1 即对方已读;群聊需所有其他成员都已读。无其他参与者时返回 false。 func messageReadByOthers(participants []readParticipant, msg model.ChatMessage) bool { others := 0 for _, p := range participants { // 排除发送者本人 if p.ParticipantType == msg.SenderType && p.ParticipantID == msg.SenderID { continue } others++ if p.LastReadAt == nil || p.LastReadAt.Before(msg.CreatedAt) { return false } } return others > 0 } 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, ListingID: row.ListingID, LatestOrderID: row.LatestOrderID, LatestOrderNo: row.LatestOrderNo, LatestOrderStatus: row.LatestOrderStatus, LatestHandoffStatus: row.LatestHandoffStatus, LatestRefundStatus: row.LatestRefundStatus, Type: row.Type, SupportScene: row.SupportScene, Title: row.Title, Status: row.Status, Role: row.Role, AdminRemark: row.AdminRemark, Participants: participants, LastMessageID: row.LastMessageID, LastMessagePreview: row.LastMessagePreview, LastMessageAt: row.LastMessageAt, LastSenderType: row.LastSenderType, LastSenderID: row.LastSenderID, LastSenderRole: row.LastSenderRole, 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 }