package chat import ( "context" "errors" "gorm.io/gorm" "gorm.io/gorm/clause" "hfb_sys/backend/internal/model" "time" ) 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). Where("c.status = ?", "active") if err := countDB.Count(&total).Error; err != nil { return nil, err } var rows []conversationRow offset := (page - 1) * pageSize err := r.conversationQuery(ctx, principal). Where("c.status = ?", "active"). 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 } 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, ListingID: conversation.ListingID, Type: conversation.Type, SupportScene: conversation.SupportScene, Title: conversation.Title, Status: conversation.Status, Role: "admin", // 管理员角色 AdminRemark: state.Remark, Participants: participants, LastMessageID: conversation.LastMessageID, LastMessagePreview: conversation.LastMessagePreview, LastMessageAt: conversation.LastMessageAt, UnreadCount: unreadCount, 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) { principal := Principal{Type: "user", ID: userID} var order model.RentalOrder if err := r.db.WithContext(ctx).First(&order, orderID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrConversationNotFound } return nil, err } if order.ListingID > 0 { var row conversationRow err := r.conversationQuery(ctx, principal). Where("c.listing_id = ? AND c.type = ?", order.ListingID, ConversationTypeListingGroup). First(&row).Error if err == nil { participants, err := r.participants(ctx, row.ID) if err != nil { return nil, err } dto := row.toDTO(participants) return &dto, nil } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } } var row conversationRow 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, scene string) (*ConversationDTO, error) { scene, title, groupCode, welcome := resolveSupportScene(scene) 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 c.support_scene = ? AND cp.participant_type = ? AND cp.participant_id = ?", ConversationTypeGeneralSupport, scene, "user", userID, ). Order("c.id ASC"). Limit(1). Find(&existing).Error if err != nil { return err } // 兼容旧数据:scene=general 时也匹配未回填 support_scene 的历史会话 if existing.ID == 0 && scene == SupportSceneGeneral { 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 (c.support_scene = '' OR c.support_scene IS NULL) AND cp.participant_type = ? AND cp.participant_id = ?", ConversationTypeGeneralSupport, "user", userID, ). Order("c.id ASC"). Limit(1). Find(&existing).Error if err != nil { return err } if existing.ID > 0 && existing.SupportScene == "" { _ = tx.Model(&existing).Update("support_scene", SupportSceneGeneral).Error } } // 兼容旧摸大红场景码 mohong → crash if existing.ID == 0 && scene == SupportSceneCrash { 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 c.support_scene = ? AND cp.participant_type = ? AND cp.participant_id = ?", ConversationTypeGeneralSupport, SupportSceneMohong, "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: ConversationTypeGeneralSupport, SupportScene: scene, Title: 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 := pickSupportAdminForScene(tx, groupCode); 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: welcome, 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 }