diff --git a/backend/internal/model/chat.go b/backend/internal/model/chat.go index d4ca2f1..afe25c1 100644 --- a/backend/internal/model/chat.go +++ b/backend/internal/model/chat.go @@ -11,6 +11,7 @@ type ChatConversation struct { OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"` ListingID *uint64 `gorm:"index" json:"listing_id"` Type string `gorm:"size:32;not null;default:'order_group'" json:"type"` + SupportScene string `gorm:"column:support_scene;size:32;not null;default:''" json:"support_scene"` Title string `gorm:"size:128;not null" json:"title"` Status string `gorm:"size:32;not null;default:'active'" json:"status"` LastMessageID *uint64 `json:"last_message_id"` diff --git a/backend/internal/modules/chat/conversation.go b/backend/internal/modules/chat/conversation.go index 7d09ab2..98453d2 100644 --- a/backend/internal/modules/chat/conversation.go +++ b/backend/internal/modules/chat/conversation.go @@ -58,6 +58,7 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal, OrderID: conversation.OrderID, ListingID: conversation.ListingID, Type: conversation.Type, + SupportScene: conversation.SupportScene, Title: conversation.Title, Status: conversation.Status, Role: "admin", // 管理员角色 @@ -131,20 +132,43 @@ func (r *Repository) FindOrderConversation(ctx context.Context, userID uint64, o dto := row.toDTO(participants) return &dto, nil } -func (r *Repository) EnsureSupportConversation(ctx context.Context, userID uint64) (*ConversationDTO, error) { +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 cp.participant_type = ? AND cp.participant_id = ?", "general_support", "user", userID). + 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 + } + } if existing.ID > 0 { conversationID = existing.ID return nil @@ -152,9 +176,10 @@ func (r *Repository) EnsureSupportConversation(ctx context.Context, userID uint6 now := time.Now() conversation := model.ChatConversation{ - Type: "general_support", - Title: "平台客服", - Status: "active", + Type: ConversationTypeGeneralSupport, + SupportScene: scene, + Title: title, + Status: "active", } if err := tx.Create(&conversation).Error; err != nil { return err @@ -169,7 +194,7 @@ func (r *Repository) EnsureSupportConversation(ctx context.Context, userID uint6 JoinedAt: now, }, } - if supportID := defaultSupportAdminID(tx); supportID > 0 { + if supportID := pickSupportAdminForScene(tx, groupCode); supportID > 0 { participants = append(participants, model.ChatParticipant{ ConversationID: conversation.ID, ParticipantType: "admin", @@ -189,7 +214,7 @@ func (r *Repository) EnsureSupportConversation(ctx context.Context, userID uint6 SenderType: "system", SenderRole: "system", ContentType: "system", - Content: "您好,客服会尽快回复,请直接描述您遇到的问题。", + Content: welcome, AttachmentURLS: emptyJSONList(), } if err := tx.Create(&message).Error; err != nil { diff --git a/backend/internal/modules/chat/dto.go b/backend/internal/modules/chat/dto.go index e817b83..a1c0690 100644 --- a/backend/internal/modules/chat/dto.go +++ b/backend/internal/modules/chat/dto.go @@ -17,6 +17,7 @@ type ConversationDTO struct { LatestHandoffStatus string `json:"latest_handoff_status,omitempty"` LatestRefundStatus string `json:"latest_refund_status,omitempty"` Type string `json:"type"` + SupportScene string `json:"support_scene,omitempty"` Title string `json:"title"` Status string `json:"status"` Role string `json:"role"` @@ -32,6 +33,12 @@ type ConversationDTO struct { UpdatedAt time.Time `json:"updated_at"` } +// EnsureSupportRequest 创建/复用客服会话。 +type EnsureSupportRequest struct { + // Scene 业务场景:general(默认)/ mohong(摸大红) + Scene string `json:"scene"` +} + type ParticipantDTO struct { ID uint64 `json:"id"` ConversationID uint64 `json:"conversation_id"` diff --git a/backend/internal/modules/chat/handler_user.go b/backend/internal/modules/chat/handler_user.go index cb2d0bd..acfce5e 100644 --- a/backend/internal/modules/chat/handler_user.go +++ b/backend/internal/modules/chat/handler_user.go @@ -62,7 +62,10 @@ func (h *Handler) EnsureSupportConversation(c *gin.Context) { response.Unauthorized(c, "缺少用户上下文") return } - item, err := h.service.EnsureSupportConversation(c.Request.Context(), userID) + var req EnsureSupportRequest + // scene 可选;空 body 仍按平台客服处理 + _ = c.ShouldBindJSON(&req) + item, err := h.service.EnsureSupportConversation(c.Request.Context(), userID, req.Scene) if err != nil { writeChatError(c, err) return diff --git a/backend/internal/modules/chat/participant.go b/backend/internal/modules/chat/participant.go index f7a4d4f..2267053 100644 --- a/backend/internal/modules/chat/participant.go +++ b/backend/internal/modules/chat/participant.go @@ -21,6 +21,7 @@ type conversationRow struct { LatestHandoffStatus string LatestRefundStatus string Type string + SupportScene string Title string Status string Role string @@ -37,7 +38,7 @@ type conversationRow struct { 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.title, c.status, c.last_message_id, + 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) @@ -300,6 +301,7 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO LatestHandoffStatus: row.LatestHandoffStatus, LatestRefundStatus: row.LatestRefundStatus, Type: row.Type, + SupportScene: row.SupportScene, Title: row.Title, Status: row.Status, Role: row.Role, diff --git a/backend/internal/modules/chat/service.go b/backend/internal/modules/chat/service.go index 3a06140..4166b4e 100644 --- a/backend/internal/modules/chat/service.go +++ b/backend/internal/modules/chat/service.go @@ -54,14 +54,14 @@ func (s *Service) FindOrderConversation(ctx context.Context, userID uint64, orde return s.repo.FindOrderConversation(ctx, userID, orderID) } -func (s *Service) EnsureSupportConversation(ctx context.Context, userID uint64) (*ConversationDTO, error) { +func (s *Service) EnsureSupportConversation(ctx context.Context, userID uint64, scene string) (*ConversationDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } if userID == 0 { return nil, ErrPermissionDenied } - return s.repo.EnsureSupportConversation(ctx, userID) + return s.repo.EnsureSupportConversation(ctx, userID, scene) } func (s *Service) Messages(ctx context.Context, principal Principal, conversationID uint64, page, pageSize int) (*PaginatedResult, error) { diff --git a/backend/internal/modules/chat/support.go b/backend/internal/modules/chat/support.go index 7686704..9d5bd36 100644 --- a/backend/internal/modules/chat/support.go +++ b/backend/internal/modules/chat/support.go @@ -213,7 +213,7 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ 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, + 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, lo.id AS latest_order_id, lo.order_no AS latest_order_no, lo.status AS latest_order_status, diff --git a/backend/internal/modules/chat/support_scene.go b/backend/internal/modules/chat/support_scene.go new file mode 100644 index 0000000..2f05773 --- /dev/null +++ b/backend/internal/modules/chat/support_scene.go @@ -0,0 +1,44 @@ +package chat + +import ( + "strings" + + "hfb_sys/backend/internal/modules/supportgroup" + + "gorm.io/gorm" +) + +// 客服入口业务场景(与前端 POST /chats/support 的 scene 对齐)。 +const ( + SupportSceneGeneral = "general" + SupportSceneMohong = "mohong" +) + +// resolveSupportScene 规范化场景并返回标题、客服分组 code、欢迎语。 +// groupCode 为空时表示不走分组,使用默认客服选取逻辑。 +func resolveSupportScene(raw string) (scene, title, groupCode, welcome string) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case SupportSceneMohong: + return SupportSceneMohong, + "摸大红客服", + supportgroup.GroupCodeMohong, + "您好,这里是摸大红客服。请直接描述问题,可将订单信息复制后发送。" + default: + // 未知场景回落平台客服,避免随意 scene 绕开分配策略 + return SupportSceneGeneral, + "平台客服", + "", + "您好,客服会尽快回复,请直接描述您遇到的问题。" + } +} + +// pickSupportAdminForScene 按分组选人;分组无成员或失败时回退默认客服逻辑。 +func pickSupportAdminForScene(tx *gorm.DB, groupCode string) uint64 { + groupCode = strings.TrimSpace(groupCode) + if groupCode != "" { + if id, err := supportgroup.PickSupportAdmin(tx, groupCode); err == nil && id > 0 { + return id + } + } + return defaultSupportAdminID(tx) +} diff --git a/backend/internal/modules/supportgroup/dto.go b/backend/internal/modules/supportgroup/dto.go index 0977bba..b8836ec 100644 --- a/backend/internal/modules/supportgroup/dto.go +++ b/backend/internal/modules/supportgroup/dto.go @@ -5,6 +5,7 @@ import "time" const ( GroupCodeOwnerOnboarding = "owner_onboarding" GroupCodeRenterHandoff = "renter_handoff" + GroupCodeMohong = "mohong" ) type MemberDTO struct { diff --git a/backend/internal/modules/supportgroup/repository.go b/backend/internal/modules/supportgroup/repository.go index bc8673f..df8f7a4 100644 --- a/backend/internal/modules/supportgroup/repository.go +++ b/backend/internal/modules/supportgroup/repository.go @@ -225,7 +225,7 @@ func normalizeStatus(status string) string { } func isProtectedCode(code string) bool { - return code == GroupCodeOwnerOnboarding || code == GroupCodeRenterHandoff + return code == GroupCodeOwnerOnboarding || code == GroupCodeRenterHandoff || code == GroupCodeMohong } func makeCustomCode(name string) string { diff --git a/backend/migrations/000030_support_scene.sql b/backend/migrations/000030_support_scene.sql new file mode 100644 index 0000000..129d95a --- /dev/null +++ b/backend/migrations/000030_support_scene.sql @@ -0,0 +1,41 @@ +-- +goose Up + +-- 客服会话按业务场景拆分(同一用户可同时有平台客服 / 摸大红客服等) +ALTER TABLE chat_conversations + ADD COLUMN support_scene VARCHAR(32) NOT NULL DEFAULT '' COMMENT '客服场景: general/mohong 等,非客服会话为空' AFTER type; + +UPDATE chat_conversations +SET support_scene = 'general' +WHERE type = 'general_support' AND (support_scene = '' OR support_scene IS NULL); + +CREATE INDEX idx_chat_conversations_type_scene ON chat_conversations (type, support_scene); + +-- 摸大红客服分组(成员可在后台「客服分组」中调整;默认挂入所有 cs 角色) +INSERT INTO chat_support_groups (code, name, description, status, sort_order) VALUES +('mohong', '摸大红-客服', '用户从摸大红入口联系客服时,按本组成员在线与负载分配', 'active', 30) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + description = VALUES(description), + sort_order = VALUES(sort_order); + +INSERT IGNORE INTO chat_support_group_members (group_id, admin_user_id) +SELECT g.id, au.id +FROM chat_support_groups g +JOIN admin_user_roles aur ON 1 = 1 +JOIN roles r ON r.id = aur.role_id +JOIN admin_users au ON au.id = aur.admin_user_id +WHERE g.code = 'mohong' + AND r.code = 'cs' + AND au.status = 'active'; + +-- +goose Down + +DELETE gm FROM chat_support_group_members gm +JOIN chat_support_groups g ON g.id = gm.group_id +WHERE g.code = 'mohong'; + +DELETE FROM chat_support_groups WHERE code = 'mohong'; + +DROP INDEX idx_chat_conversations_type_scene ON chat_conversations; + +ALTER TABLE chat_conversations DROP COLUMN support_scene; diff --git a/frontend/src/features/chats/api/chats.ts b/frontend/src/features/chats/api/chats.ts index 84f94fa..c9cec47 100644 --- a/frontend/src/features/chats/api/chats.ts +++ b/frontend/src/features/chats/api/chats.ts @@ -14,6 +14,8 @@ export interface ChatParticipant { joined_at: string } +export type SupportScene = 'general' | 'mohong' + export interface ChatConversation { id: number order_id: number | null @@ -24,6 +26,7 @@ export interface ChatConversation { latest_handoff_status?: string latest_refund_status?: string type: string + support_scene?: string title: string status: string role: 'renter' | 'owner' | 'support' | 'customer' | 'admin' @@ -39,6 +42,19 @@ export interface ChatConversation { updated_at: string } +/** 根据当前路由推断客服场景:摸大红页 → mohong,其它 → general */ +export function resolveSupportScene(path: string): SupportScene { + if ( + path === '/mohong' || + path.startsWith('/mohong/') || + path === '/m/mohong' || + path.startsWith('/m/mohong/') + ) { + return 'mohong' + } + return 'general' +} + export interface AdminChatCounts { ownership?: Record stages?: Record @@ -97,8 +113,10 @@ export async function fetchOrderChat(orderId: number) { return data.data } -export async function ensureSupportChat() { - const { data } = await apiClient.post>('/chats/support') +export async function ensureSupportChat(scene: SupportScene | string = 'general') { + const { data } = await apiClient.post>('/chats/support', { + scene: scene || 'general', + }) return data.data } diff --git a/frontend/src/features/listings/views/MobileHomeView.vue b/frontend/src/features/listings/views/MobileHomeView.vue index 0bc454d..8ffcfe1 100644 --- a/frontend/src/features/listings/views/MobileHomeView.vue +++ b/frontend/src/features/listings/views/MobileHomeView.vue @@ -3,7 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { onBeforeRouteLeave, RouterLink, useRoute, useRouter } from 'vue-router' import { showToast } from 'vant' import MobileBottomNav from '@/components/MobileBottomNav.vue' -import { ensureSupportChat } from '@/features/chats/api/chats' +import { ensureSupportChat, resolveSupportScene } from '@/features/chats/api/chats' import { formatCent, formatMoney } from '@/shared/utils/money' import { emptyListingPublishOptions, @@ -158,7 +158,8 @@ async function handleSupportClick() { if (supportLoading.value) return supportLoading.value = true try { - const chat = await ensureSupportChat() + const scene = resolveSupportScene(route.path) + const chat = await ensureSupportChat(scene) router.push(`/m/chats/${chat.id}`) } catch { showToast({ message: '联系客服失败,请稍后重试', icon: 'cross' }) diff --git a/frontend/src/features/mohong/views/MobileMohongListView.vue b/frontend/src/features/mohong/views/MobileMohongListView.vue index f5c83e3..b61f9ac 100644 --- a/frontend/src/features/mohong/views/MobileMohongListView.vue +++ b/frontend/src/features/mohong/views/MobileMohongListView.vue @@ -3,17 +3,21 @@ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { showToast } from 'vant' import MobileBottomNav from '@/components/MobileBottomNav.vue' +import { ensureSupportChat } from '@/features/chats/api/chats' import { fetchMohongCategories, fetchMohongProducts, type MohongCategory, type MohongProduct, } from '@/features/mohong/api/mohong' +import { useSessionStore } from '@/stores/session' import { formatCent } from '@/shared/utils/money' import { readError } from '@/shared/utils/error' const router = useRouter() const route = useRoute() +const session = useSessionStore() +const supportLoading = ref(false) const loading = ref(false) const products = ref([]) const categories = ref([]) @@ -153,6 +157,23 @@ function markCoverFailed(id: number) { function showCover(item: MohongProduct) { return Boolean(item.cover_url) && !failedCover.value[item.id] } + +async function handleSupportClick() { + if (!session.isLoggedIn) { + router.push({ path: '/m/login', query: { redirect: route.fullPath } }) + return + } + if (supportLoading.value) return + supportLoading.value = true + try { + const chat = await ensureSupportChat('mohong') + router.push(`/m/chats/${chat.id}`) + } catch { + showToast({ message: '联系客服失败,请稍后重试', icon: 'cross' }) + } finally { + supportLoading.value = false + } +}