package chat import ( "context" "errors" "fmt" "gorm.io/gorm" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/chathub" "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 { err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if principal.Type != "admin" { return ErrPermissionDenied } var conversation model.ChatConversation if err := tx.First(&conversation, conversationID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrConversationNotFound } return err } // 验证目标客服存在、活跃且拥有客服角色,避免转接给超级管理员。 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("该客服已在会话中") } var current model.ChatParticipant currentErr := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, "admin", principal.ID). First(¤t).Error if currentErr == nil { if current.Role != "support" { return ErrPermissionDenied } // 只转接当前客服本人,避免误删同群里的其他客服。 if err := tx.Model(¤t).Updates(map[string]interface{}{ "participant_id": toAdminID, "joined_at": time.Now(), "last_read_at": nil, }).Error; err != nil { return err } } else if errors.Is(currentErr, gorm.ErrRecordNotFound) { // 未分配会话允许从“全部/未分配”直接指派给目标客服。 if err := tx.Create(&model.ChatParticipant{ ConversationID: conversationID, ParticipantType: "admin", ParticipantID: toAdminID, Role: "support", JoinedAt: time.Now(), }).Error; err != nil { return err } } else { return currentErr } if err := sendSystemMessage(tx, conversationID, "会话已转接给其他客服"); err != nil { return err } return nil }) if err == nil && r.hub != nil { r.hub.NotifyConversation(conversationID, &chathub.ChatEvent{Type: "conversation_updated", ConversationID: conversationID}) r.hub.NotifyAllAdmins(&chathub.ChatEvent{Type: "conversation_updated", ConversationID: conversationID}) } return err } 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) { total, err := r.adminConversationTotal(ctx, principal, filter, stage, keyword) if 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.support_scene, 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, COALESCE(cas.remark, '') AS admin_remark, COALESCE(explicit_lo.id, listing_lo.id) AS latest_order_id, COALESCE(explicit_lo.order_no, listing_lo.order_no) AS latest_order_no, COALESCE(explicit_lo.status, listing_lo.status) AS latest_order_status, COALESCE(explicit_lo.handoff_status, listing_lo.handoff_status) AS latest_handoff_status, COALESCE(explicit_lo.refund_status, listing_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 c.type = 'general_support' AND (` + adminNeedsReplyExpression() + `) THEN 1 ELSE 0 END AS needs_reply, ( SELECT COUNT(1) FROM chat_messages AS cm WHERE cm.conversation_id = c.id AND (cm.admin_attention_type <> '' OR cm.sender_type = 'user') AND cm.id > COALESCE(cas.last_read_message_id, 0) ) AS unread_count`) 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 } ids := make([]uint64, 0, len(rows)) for _, row := range rows { ids = append(ids, row.ID) } participantsByConversation, err := r.participantsForConversations(ctx, ids) if err != nil { return nil, err } items := make([]ConversationDTO, 0, len(rows)) for _, row := range rows { items = append(items, row.toDTO(participantsByConversation[row.ID])) } return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } func (r *Repository) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) { result, err := r.adminConversationCounts(ctx, principal, normalizeAdminChatFilter(filter), normalizeAdminChatStage(stage), strings.TrimSpace(keyword)) if err != nil { return nil, err } return result.DTO, nil } func (r *Repository) adminConversationTotal(ctx context.Context, principal Principal, filter string, stage string, keyword string) (int64, error) { db := r.adminConversationBase(ctx, principal, strings.TrimSpace(keyword)) applyAdminChatOwnershipFilter(db, normalizeAdminChatFilter(filter), principal) applyAdminChatStageFilter(db, normalizeAdminChatStage(stage), principal) var total int64 if err := db.Select("COUNT(DISTINCT c.id)").Scan(&total).Error; err != nil { return 0, err } return total, 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_admin_conversation_states AS cas ON cas.conversation_id = c.id AND cas.admin_user_id = ?", principal.ID). Joins("LEFT JOIN chat_messages AS lm ON lm.id = c.last_message_id"). Joins("LEFT JOIN rental_orders AS explicit_lo ON explicit_lo.id = c.order_id"). Joins("LEFT JOIN (SELECT ro.* FROM rental_orders AS ro JOIN (SELECT listing_id, MAX(id) AS max_id FROM rental_orders GROUP BY listing_id) AS latest ON latest.max_id = ro.id) AS listing_lo ON listing_lo.listing_id = c.listing_id AND c.order_id IS NULL"). Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(explicit_lo.listing_id, listing_lo.listing_id, c.listing_id)") if keyword != "" { db = db.Joins("LEFT JOIN users AS renter ON renter.id = COALESCE(explicit_lo.renter_id, listing_lo.renter_id)"). Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(explicit_lo.owner_id, listing_lo.owner_id, l.owner_id)") like := "%" + keyword + "%" db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR COALESCE(explicit_lo.order_no, listing_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 } type adminConversationCountsResult struct { Total int64 DTO *AdminConversationCountsDTO } func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*adminConversationCountsResult, error) { ownership := make(map[string]string, len(adminChatFilters)) ownershipArgs := make(map[string][]interface{}, len(adminChatFilters)) for _, item := range adminChatFilters { ownership[item], ownershipArgs[item] = adminChatOwnershipCondition(item, principal) } stages := make(map[string]string, len(adminChatStages)) stageArgs := make(map[string][]interface{}, len(adminChatStages)) for _, item := range adminChatStages { stages[item], stageArgs[item] = adminChatStageCondition(item, principal) } parts := make([]string, 0, 1+len(adminChatFilters)+len(adminChatStages)) args := make([]interface{}, 0) addCount := func(alias, left, right string, leftArgs, rightArgs []interface{}) { parts = append(parts, "SUM(CASE WHEN "+left+" AND "+right+" THEN 1 ELSE 0 END) AS "+alias) args = append(args, leftArgs...) args = append(args, rightArgs...) } addCount("total", ownership[filter], stages[stage], ownershipArgs[filter], stageArgs[stage]) for _, item := range adminChatFilters { addCount("ownership_"+item, ownership[item], stages[stage], ownershipArgs[item], stageArgs[stage]) } for _, item := range adminChatStages { addCount("stage_"+item, ownership[filter], stages[item], ownershipArgs[filter], stageArgs[item]) } type row struct { Total int64 OwnershipMine, OwnershipAll, OwnershipUnassigned int64 StageAll, StagePending, StageUnjoined, StageHandoff, StageRenting, StageAfterSale, StageEnded int64 } var result row if err := r.adminConversationBase(ctx, principal, keyword).Select(strings.Join(parts, ", "), args...).Scan(&result).Error; err != nil { return nil, err } return &adminConversationCountsResult{ Total: result.Total, DTO: &AdminConversationCountsDTO{ Ownership: map[string]int64{adminChatFilterMine: result.OwnershipMine, adminChatFilterAll: result.OwnershipAll, adminChatFilterUnassigned: result.OwnershipUnassigned}, Stages: map[string]int64{adminChatStageAll: result.StageAll, adminChatStagePending: result.StagePending, adminChatStageUnjoined: result.StageUnjoined, adminChatStageHandoff: result.StageHandoff, adminChatStageRenting: result.StageRenting, adminChatStageAfterSale: result.StageAfterSale, adminChatStageEnded: result.StageEnded}, }, }, 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) { condition, args := adminChatOwnershipCondition(filter, principal) db.Where(condition, args...) } func applyAdminChatStageFilter(db *gorm.DB, stage string, principal Principal) { condition, args := adminChatStageCondition(stage, principal) db.Where(condition, args...) } func adminChatOwnershipCondition(filter string, principal Principal) (string, []interface{}) { switch filter { case adminChatFilterAll: return "1 = 1", nil case adminChatFilterUnassigned: return `NOT EXISTS ( SELECT 1 FROM chat_participants AS cp_support WHERE cp_support.conversation_id = c.id AND cp_support.participant_type = 'admin' AND cp_support.role = 'support' )`, nil case adminChatFilterMine: fallthrough default: return "cp_me.id IS NOT NULL", nil } } func adminChatStageCondition(stage string, principal Principal) (string, []interface{}) { latestID := "COALESCE(explicit_lo.id, listing_lo.id)" latestStatus := "COALESCE(explicit_lo.status, listing_lo.status)" latestRefundStatus := "COALESCE(explicit_lo.refund_status, listing_lo.refund_status)" switch stage { case adminChatStagePending: return "c.type = 'general_support' AND (" + adminNeedsReplyExpression() + ")", nil case adminChatStageUnjoined: return latestID + " IS NULL", nil case adminChatStageHandoff: return latestStatus + " = 'pending_handoff'", nil case adminChatStageRenting: return latestStatus + " IN ('renting', 'overdue')", nil case adminChatStageAfterSale: return "(" + latestStatus + " IN ('pending_checkout_confirm', 'pending_checkout_accept', 'checkout_disputing', 'abnormal') OR (" + latestRefundStatus + " IS NOT NULL AND " + latestRefundStatus + " <> 'none'))", nil case adminChatStageEnded: return "(c.status IN ('archived', 'closed') OR " + latestStatus + " IN ('completed', 'cancelled', 'closed'))", nil case adminChatStageAll: fallthrough default: return "1 = 1", nil } } func adminNeedsReplyExpression() string { return `COALESCE(( SELECT MAX(cm_attention.id) FROM chat_messages AS cm_attention WHERE cm_attention.conversation_id = c.id AND (cm_attention.admin_attention_type <> '' OR cm_attention.sender_type = 'user') ), 0) > COALESCE(( SELECT MAX(cm_admin.id) FROM chat_messages AS cm_admin WHERE cm_admin.conversation_id = c.id AND cm_admin.sender_type = 'admin' ), 0)` } // ArchiveListingConversation 将商品关联的发布群标记为已归档(解散),并写入系统提示。 // 用于客服封存订单时同事务解散群聊;发布群不存在时静默跳过,不阻断封存流程。 // 归档后 SendMessage 的 status 校验会阻断所有成员继续发言。 func ArchiveListingConversation(tx *gorm.DB, listingID uint64, reason string) error { var conversation model.ChatConversation err := tx.Where("listing_id = ?", listingID).First(&conversation).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil } return err } if conversation.Status == "archived" { return nil } if err := tx.Model(&model.ChatConversation{}). Where("id = ?", conversation.ID). Update("status", "archived").Error; err != nil { return err } content := "群聊已由客服解散,订单已封存。" if trimmed := strings.TrimSpace(reason); trimmed != "" { content += "原因:" + trimmed } return sendSystemMessage(tx, conversation.ID, content) }