package chat import ( "context" "fmt" "gorm.io/gorm" "hfb_sys/backend/internal/model" "strings" "time" ) const ( adminChatFilterAll = "all" adminChatFilterMine = "mine" adminChatFilterUnassigned = "unassigned" adminChatStageAll = "all" adminChatStagePending = "pending" adminChatStageUnjoined = "unjoined" adminChatStageHandoff = "handoff" adminChatStageRenting = "renting" adminChatStageAfterSale = "after_sale" adminChatStageEnded = "ended" ) var adminChatFilters = []string{adminChatFilterMine, adminChatFilterAll, adminChatFilterUnassigned} var adminChatStages = []string{adminChatStageAll, adminChatStagePending, adminChatStageUnjoined, adminChatStageHandoff, adminChatStageRenting, adminChatStageAfterSale, adminChatStageEnded} type AdminConversationCountsDTO struct { Ownership map[string]int64 `json:"ownership"` Stages map[string]int64 `json:"stages"` } 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 { // 验证当前操作者是会话参与者 current, err := r.findParticipant(tx, principal, conversationID, false) if err != nil { return err } if current.Role != "support" || current.ParticipantType != "admin" { return ErrPermissionDenied } // 验证目标客服存在、活跃且拥有客服角色,避免转接给超级管理员。 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.Model(&model.ChatParticipant{}). Where("id = ?", current.ID). Updates(map[string]interface{}{ "participant_id": toAdminID, "joined_at": time.Now(), }).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 }) } 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 } func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) { page, pageSize = normalizePagination(page, pageSize) filter = normalizeAdminChatFilter(filter) stage = normalizeAdminChatStage(stage) keyword = strings.TrimSpace(keyword) if principal.Type == "admin" { return r.listAdminConversations(ctx, principal, page, pageSize, filter, stage, keyword) } // 其他情况使用原有逻辑 var total int64 db := r.db.WithContext(ctx) 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 (r *Repository) listAdminConversations(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) { var total int64 countDB := r.adminConversationBase(ctx, principal, keyword) applyAdminChatOwnershipFilter(countDB, filter, principal) applyAdminChatStageFilter(countDB, stage, principal) if err := countDB.Count(&total).Error; err != nil { return nil, err } var rows []conversationRow offset := (page - 1) * pageSize queryDB := r.adminConversationBase(ctx, principal, keyword). Select(`c.id, c.order_id, c.listing_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, COALESCE(cp_me.role, 'admin') AS role, lo.id AS latest_order_id, lo.order_no AS latest_order_no, lo.status AS latest_order_status, lo.handoff_status AS latest_handoff_status, lo.refund_status AS latest_refund_status, lm.sender_type AS last_sender_type, lm.sender_id AS last_sender_id, lm.sender_role AS last_sender_role, CASE WHEN cp_me.id IS NULL THEN 0 ELSE ( 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_me.last_read_at IS NULL OR cm.created_at > cp_me.last_read_at) ) END AS unread_count`, principal.Type, principal.ID) applyAdminChatOwnershipFilter(queryDB, filter, principal) applyAdminChatStageFilter(queryDB, stage, principal) if err := queryDB. Order("COALESCE(c.last_message_at, c.created_at) DESC, c.id DESC"). Offset(offset). Limit(pageSize). Scan(&rows).Error; 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)) } counts, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword) if err != nil { return nil, err } return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize, Counts: counts}, nil } func (r *Repository) adminConversationBase(ctx context.Context, principal Principal, keyword string) *gorm.DB { db := r.db.WithContext(ctx).Table("chat_conversations AS c"). Joins("LEFT JOIN chat_participants AS cp_me ON cp_me.conversation_id = c.id AND cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID). Joins("LEFT JOIN chat_messages AS lm ON lm.id = c.last_message_id"). Joins("LEFT JOIN rental_orders AS lo ON lo.id = COALESCE(c.order_id, (SELECT ro.id FROM rental_orders AS ro WHERE ro.listing_id = c.listing_id ORDER BY ro.id DESC LIMIT 1))"). Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(lo.listing_id, c.listing_id)"). Joins("LEFT JOIN users AS renter ON renter.id = lo.renter_id"). Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(lo.owner_id, l.owner_id)") if keyword != "" { like := "%" + keyword + "%" db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR lo.order_no LIKE ? OR l.listing_no LIKE ? OR renter.phone LIKE ? OR owner.phone LIKE ? OR EXISTS ( SELECT 1 FROM chat_participants AS cp_kw WHERE cp_kw.conversation_id = c.id AND cp_kw.remark LIKE ? )`, like, like, like, like, like, like, like) } return db } func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) { counts := &AdminConversationCountsDTO{ Ownership: make(map[string]int64, len(adminChatFilters)), Stages: make(map[string]int64, len(adminChatStages)), } for _, item := range adminChatFilters { db := r.adminConversationBase(ctx, principal, keyword) applyAdminChatOwnershipFilter(db, item, principal) applyAdminChatStageFilter(db, stage, principal) var count int64 if err := db.Count(&count).Error; err != nil { return nil, err } counts.Ownership[item] = count } for _, item := range adminChatStages { db := r.adminConversationBase(ctx, principal, keyword) applyAdminChatOwnershipFilter(db, filter, principal) applyAdminChatStageFilter(db, item, principal) var count int64 if err := db.Count(&count).Error; err != nil { return nil, err } counts.Stages[item] = count } return counts, nil } func normalizeAdminChatFilter(filter string) string { switch filter { case adminChatFilterAll, adminChatFilterMine, adminChatFilterUnassigned: return filter default: return adminChatFilterMine } } func normalizeAdminChatStage(stage string) string { switch stage { case adminChatStageAll, adminChatStagePending, adminChatStageUnjoined, adminChatStageHandoff, adminChatStageRenting, adminChatStageAfterSale, adminChatStageEnded: return stage default: return adminChatStageAll } } func applyAdminChatOwnershipFilter(db *gorm.DB, filter string, principal Principal) { switch filter { case adminChatFilterMine: db.Where("cp_me.id IS NOT NULL") case adminChatFilterUnassigned: db.Where(`NOT EXISTS ( SELECT 1 FROM chat_participants AS cp_support WHERE cp_support.conversation_id = c.id AND cp_support.participant_type = ? AND cp_support.role = ? )`, "admin", "support") case adminChatFilterAll: return default: db.Where("cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID) } } func applyAdminChatStageFilter(db *gorm.DB, stage string, principal Principal) { switch stage { case adminChatStagePending: db.Where(`( lm.sender_type = ? OR ( cp_me.id IS NOT NULL AND EXISTS ( SELECT 1 FROM chat_messages AS cm_pending WHERE cm_pending.conversation_id = c.id AND NOT (cm_pending.sender_type = ? AND cm_pending.sender_id = ?) AND (cp_me.last_read_at IS NULL OR cm_pending.created_at > cp_me.last_read_at) ) ) )`, "user", principal.Type, principal.ID) case adminChatStageUnjoined: db.Where("lo.id IS NULL") case adminChatStageHandoff: db.Where("lo.status = ?", "pending_handoff") case adminChatStageRenting: db.Where("lo.status IN ?", []string{"renting", "overdue"}) case adminChatStageAfterSale: db.Where("(lo.status IN ? OR (lo.refund_status IS NOT NULL AND lo.refund_status <> ?))", []string{"pending_checkout_confirm", "pending_checkout_accept", "checkout_disputing", "abnormal"}, "none") case adminChatStageEnded: db.Where("(c.status IN ? OR lo.status IN ?)", []string{"archived", "closed"}, []string{"completed", "cancelled", "closed"}) case adminChatStageAll: return } }