package chat import ( "context" "errors" "fmt" "strings" "time" "hfb_sys/backend/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) func (r *Repository) resolveSite(ctx context.Context, siteCode string) (*model.DistributionSite, error) { siteCode = strings.TrimSpace(siteCode) if siteCode == "" { return nil, nil } var site model.DistributionSite if err := r.db.WithContext(ctx).Where("site_code = ? AND status = 'active'", siteCode).First(&site).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("distribution site unavailable") } return nil, err } return &site, nil } // userSiteConversationFilter 限制用户在当前站点能看到的会话。 // 账号群只有一个:本站发布的群按站点归属显示;跨站成交时,租客在下单的站点也能看到这个群。 func userSiteConversationFilter(siteID uint64, userID uint64) (string, []any) { if siteID > 0 { filter := `( c.distribution_site_id = ? OR ( c.order_id IS NOT NULL AND c.order_id IN ( SELECT ro.id FROM rental_orders ro WHERE ro.distribution_site_id = ? AND ro.distribution_mode != 'two_party_distributor_receive' ) ) OR ( c.type = ? AND c.listing_id IN ( SELECT ro.listing_id FROM rental_orders ro WHERE ro.distribution_site_id = ? AND ro.renter_id = ? AND ro.distribution_mode != 'two_party_distributor_receive' ) ) )` return filter, []any{siteID, siteID, ConversationTypeListingGroup, siteID, userID} } filter := `( ( (c.distribution_site_id IS NULL OR c.distribution_site_id = 0) AND ( c.order_id IS NULL OR c.order_id NOT IN ( SELECT ro.id FROM rental_orders ro WHERE ro.distribution_site_id IS NOT NULL AND ro.distribution_site_id > 0 AND ro.distribution_mode != 'two_party_distributor_receive' ) ) ) OR ( c.type = ? AND c.listing_id IN ( SELECT ro.listing_id FROM rental_orders ro WHERE ro.renter_id = ? AND ro.distribution_mode = 'two_party_distributor_receive' ) ) )` return filter, []any{ConversationTypeListingGroup, userID} } func (r *Repository) ListConversations(ctx context.Context, principal Principal, page, pageSize int, siteCode ...string) (*PaginatedResult, error) { page, pageSize = normalizePagination(page, pageSize) db := r.db.WithContext(ctx) var code string if len(siteCode) > 0 { code = siteCode[0] } site, err := r.resolveSite(ctx, code) if err != nil { return nil, err } 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") dataQuery := r.conversationQuery(ctx, principal). Where("c.status = ?", "active") if principal.Type == "user" { siteID := uint64(0) if site != nil { siteID = site.ID } filter, args := userSiteConversationFilter(siteID, principal.ID) countDB = countDB.Where(filter, args...) dataQuery = dataQuery.Where(filter, args...) } var total int64 if err := countDB.Count(&total).Error; err != nil { return nil, err } var rows []conversationRow offset := (page - 1) * pageSize err = dataQuery. 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.admin_attention_type <> ? OR cm.sender_type = ?)", "", "user"). Where("cm.id > ?", state.LastReadMessageID). Count(&unreadCount).Error; err != nil { return nil, err } dto := ConversationDTO{ ID: conversation.ID, OrderID: conversation.OrderID, ListingID: conversation.ListingID, DistributionSiteID: conversation.DistributionSiteID, 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, siteCode ...string) (*ConversationDTO, error) { var code string if len(siteCode) > 0 { code = siteCode[0] } site, err := r.resolveSite(ctx, code) if err != nil { return nil, err } scene, title, groupCode, welcome := resolveSupportScene(scene) if site != nil { title = fmt.Sprintf("【%s】%s", site.SiteName, title) welcome = fmt.Sprintf("您好,欢迎咨询【%s】在线客服!请直接描述您遇到的问题,客服会尽快回复。", site.SiteName) } var conversationID uint64 err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var existing model.ChatConversation existingQuery := 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, ) if site != nil { existingQuery = existingQuery.Where("c.distribution_site_id = ?", site.ID) } else { existingQuery = existingQuery.Where("c.distribution_site_id IS NULL OR c.distribution_site_id = 0") } err := existingQuery.Order("c.id ASC").Limit(1).Find(&existing).Error if err != nil { return err } // 兼容旧数据:主站 scene=general 时也匹配未回填 support_scene 的历史会话 if site == nil && 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 = ? AND (c.distribution_site_id IS NULL OR c.distribution_site_id = 0)", 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 site == nil && 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 = ? AND (c.distribution_site_id IS NULL OR c.distribution_site_id = 0)", 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 site != nil { conversation.DistributionSiteID = &site.ID } if err := tx.Create(&conversation).Error; err != nil { return err } participants := []model.ChatParticipant{ { ConversationID: conversation.ID, ParticipantType: "user", ParticipantID: userID, Role: "customer", JoinedAt: now, }, } var supportID uint64 if site != nil && site.DefaultSupportGroupID != nil && *site.DefaultSupportGroupID > 0 { var member model.ChatSupportGroupMember if err := tx.Where("group_id = ?", *site.DefaultSupportGroupID).Order("id ASC").First(&member).Error; err == nil && member.AdminID > 0 { supportID = member.AdminID } } if supportID == 0 { supportID = pickSupportAdminForScene(tx, groupCode) } if 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 }