From 233920025eebfbd1a56e92edfa525ea67140878c Mon Sep 17 00:00:00 2001 From: yml Date: Thu, 18 Jun 2026 02:09:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AE=A2=E6=9C=8D=E5=88=86?= =?UTF-8?q?=E7=BB=84=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/model/chat.go | 26 ++ .../internal/modules/chat/listing_group.go | 38 ++- backend/internal/modules/chat/support.go | 27 +- backend/internal/modules/supportgroup/dto.go | 48 ++++ .../internal/modules/supportgroup/errors.go | 9 + .../internal/modules/supportgroup/handler.go | 133 +++++++++ .../modules/supportgroup/repository.go | 256 +++++++++++++++++ .../internal/modules/supportgroup/selector.go | 57 ++++ .../internal/modules/supportgroup/service.go | 77 +++++ backend/internal/router/router.go | 16 ++ .../migrations/000011_chat_support_groups.sql | 69 +++++ .../src/features/admin/api/supportGroups.ts | 66 +++++ frontend/src/features/admin/index.ts | 1 + .../admin/views/AdminSupportGroupsView.vue | 272 ++++++++++++++++++ frontend/src/layouts/AdminLayout.vue | 1 + frontend/src/router/adminRoutes.ts | 6 + 16 files changed, 1082 insertions(+), 20 deletions(-) create mode 100644 backend/internal/modules/supportgroup/dto.go create mode 100644 backend/internal/modules/supportgroup/errors.go create mode 100644 backend/internal/modules/supportgroup/handler.go create mode 100644 backend/internal/modules/supportgroup/repository.go create mode 100644 backend/internal/modules/supportgroup/selector.go create mode 100644 backend/internal/modules/supportgroup/service.go create mode 100644 backend/migrations/000011_chat_support_groups.sql create mode 100644 frontend/src/features/admin/api/supportGroups.ts create mode 100644 frontend/src/features/admin/views/AdminSupportGroupsView.vue diff --git a/backend/internal/model/chat.go b/backend/internal/model/chat.go index 674ab97..1d89bcf 100644 --- a/backend/internal/model/chat.go +++ b/backend/internal/model/chat.go @@ -73,3 +73,29 @@ type ChatQrCode struct { func (ChatQrCode) TableName() string { return "chat_qrcode_pool" } + +type ChatSupportGroup struct { + ID uint64 `gorm:"primaryKey" json:"id"` + Code string `gorm:"size:64;not null;uniqueIndex" json:"code"` + Name string `gorm:"size:64;not null" json:"name"` + Description string `gorm:"size:255;not null;default:''" json:"description"` + Status string `gorm:"size:16;not null;default:'active';index" json:"status"` + SortOrder int `gorm:"not null;default:0" json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (ChatSupportGroup) TableName() string { + return "chat_support_groups" +} + +type ChatSupportGroupMember struct { + ID uint64 `gorm:"primaryKey" json:"id"` + GroupID uint64 `gorm:"not null;uniqueIndex:uk_support_group_member" json:"group_id"` + AdminID uint64 `gorm:"column:admin_user_id;not null;uniqueIndex:uk_support_group_member;index" json:"admin_id"` + CreatedAt time.Time `json:"created_at"` +} + +func (ChatSupportGroupMember) TableName() string { + return "chat_support_group_members" +} diff --git a/backend/internal/modules/chat/listing_group.go b/backend/internal/modules/chat/listing_group.go index 5a15ed1..717eb48 100644 --- a/backend/internal/modules/chat/listing_group.go +++ b/backend/internal/modules/chat/listing_group.go @@ -8,6 +8,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/supportgroup" ) // 会话类型常量 @@ -56,10 +57,16 @@ func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, preferr }, } - // 获取客服。开放上传场景优先拉上传管理员,普通发布回退到默认客服。 - supportID := preferredSupportAdminID + // 获取收号组客服。分组未配置时回退到开放上传管理员或默认客服。 + supportID, err := supportgroup.PickSupportAdmin(tx, supportgroup.GroupCodeOwnerOnboarding) + if err != nil { + return nil, err + } if supportID <= 0 { - supportID = defaultSupportAdminID(tx) + supportID = preferredSupportAdminID + if supportID <= 0 { + supportID = defaultSupportAdminID(tx) + } } if supportID > 0 { participants = append(participants, model.ChatParticipant{ @@ -123,7 +130,7 @@ func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, preferr return &conversation, nil } -// AddRenterToListingConversation 拉租客进发布群 +// AddRenterToListingConversation 拉租客与卖号组客服进发布群 func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint64, orderNo string) error { // 1. 查找发布群 var conv model.ChatConversation @@ -150,8 +157,29 @@ func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint return err } - // 3. 发系统消息 + // 3. 支付后由卖号组客服接入跟进交接和售后。 + handoffSupportID, err := supportgroup.PickSupportAdmin(tx, supportgroup.GroupCodeRenterHandoff) + if err != nil { + return err + } + if handoffSupportID > 0 { + support := model.ChatParticipant{ + ConversationID: conv.ID, + ParticipantType: "admin", + ParticipantID: handoffSupportID, + Role: "support", + JoinedAt: now, + } + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&support).Error; err != nil { + return err + } + } + + // 4. 发系统消息 message := "租客已加入群聊(订单 " + orderNo + ")" + if handoffSupportID > 0 { + message += ",卖号组客服已接入" + } return sendSystemMessage(tx, conv.ID, message) } diff --git a/backend/internal/modules/chat/support.go b/backend/internal/modules/chat/support.go index cde9f26..9ce124c 100644 --- a/backend/internal/modules/chat/support.go +++ b/backend/internal/modules/chat/support.go @@ -34,9 +34,13 @@ type AdminConversationCountsDTO struct { 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 { // 验证当前操作者是会话参与者 - if _, err := r.findParticipant(tx, principal, conversationID, false); err != nil { + 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("目标客服不存在、已禁用或不是客服角色") @@ -51,20 +55,13 @@ func (r *Repository) TransferConversation(ctx context.Context, principal Princip if count > 0 { return fmt.Errorf("该客服已在会话中") } - // 删除原客服参与者 - if err := tx.Where("conversation_id = ? AND participant_type = ? AND role = ?", conversationID, "admin", "support"). - Delete(&model.ChatParticipant{}).Error; err != nil { - return err - } - // 添加新客服参与者 - participant := model.ChatParticipant{ - ConversationID: conversationID, - ParticipantType: "admin", - ParticipantID: toAdminID, - Role: "support", - JoinedAt: time.Now(), - } - if err := tx.Create(&participant).Error; err != nil { + // 只转接当前客服本人,避免误删同群里的收号组/卖号组其他客服。 + 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 } // 添加系统消息记录转接 diff --git a/backend/internal/modules/supportgroup/dto.go b/backend/internal/modules/supportgroup/dto.go new file mode 100644 index 0000000..0977bba --- /dev/null +++ b/backend/internal/modules/supportgroup/dto.go @@ -0,0 +1,48 @@ +package supportgroup + +import "time" + +const ( + GroupCodeOwnerOnboarding = "owner_onboarding" + GroupCodeRenterHandoff = "renter_handoff" +) + +type MemberDTO struct { + ID uint64 `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + SupportStatus string `json:"support_status"` +} + +type GroupDTO struct { + ID uint64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + SortOrder int `json:"sort_order"` + Members []MemberDTO `json:"members"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CreateGroupRequest struct { + Code string `json:"code"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Status string `json:"status"` + SortOrder int `json:"sort_order"` + MemberIDs []uint64 `json:"member_ids"` +} + +type UpdateGroupRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Status string `json:"status"` + SortOrder int `json:"sort_order"` + MemberIDs []uint64 `json:"member_ids"` +} + +type AssignMembersRequest struct { + MemberIDs []uint64 `json:"member_ids"` +} diff --git a/backend/internal/modules/supportgroup/errors.go b/backend/internal/modules/supportgroup/errors.go new file mode 100644 index 0000000..3f11142 --- /dev/null +++ b/backend/internal/modules/supportgroup/errors.go @@ -0,0 +1,9 @@ +package supportgroup + +import "errors" + +var ( + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrInvalidGroup = errors.New("invalid support group") + ErrProtectedGroup = errors.New("protected support group") +) diff --git a/backend/internal/modules/supportgroup/handler.go b/backend/internal/modules/supportgroup/handler.go new file mode 100644 index 0000000..643ccce --- /dev/null +++ b/backend/internal/modules/supportgroup/handler.go @@ -0,0 +1,133 @@ +package supportgroup + +import ( + "errors" + "net/http" + "strconv" + + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) List(c *gin.Context) { + items, err := h.service.List(c.Request.Context()) + if err != nil { + writeError(c, err) + return + } + response.OK(c, items) +} + +func (h *Handler) FindByID(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.FindByID(c.Request.Context(), id) + if err != nil { + writeError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Create(c *gin.Context) { + var req CreateGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "分组名称不能为空") + return + } + item, err := h.service.Create(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + response.Created(c, item) +} + +func (h *Handler) Update(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req UpdateGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "分组名称不能为空") + return + } + item, err := h.service.Update(c.Request.Context(), id, req) + if err != nil { + writeError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Delete(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + if err := h.service.Delete(c.Request.Context(), id); err != nil { + writeError(c, err) + return + } + response.OK(c, gin.H{"deleted": true}) +} + +func (h *Handler) AssignMembers(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req AssignMembersRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求格式不正确") + return + } + if err := h.service.AssignMembers(c.Request.Context(), id, req); err != nil { + writeError(c, err) + return + } + response.OK(c, gin.H{"updated": true}) +} + +func (h *Handler) ListSupportAdmins(c *gin.Context) { + items, err := h.service.ListSupportAdmins(c.Request.Context()) + if err != nil { + writeError(c, err) + return + } + response.OK(c, items) +} + +func parseID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "ID 不正确") + return 0, false + } + return id, true +} + +func writeError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrInvalidGroup): + response.BadRequest(c, "客服分组信息不正确") + case errors.Is(err, ErrProtectedGroup): + response.Error(c, http.StatusConflict, "protected_group", "默认分组不能删除") + case IsNotFound(err): + response.NotFound(c, "客服分组不存在") + default: + response.Error(c, http.StatusInternalServerError, "internal_error", "客服分组操作失败") + } +} diff --git a/backend/internal/modules/supportgroup/repository.go b/backend/internal/modules/supportgroup/repository.go new file mode 100644 index 0000000..bc8673f --- /dev/null +++ b/backend/internal/modules/supportgroup/repository.go @@ -0,0 +1,256 @@ +package supportgroup + +import ( + "context" + "errors" + "strings" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) List(ctx context.Context) ([]GroupDTO, error) { + var groups []model.ChatSupportGroup + if err := r.db.WithContext(ctx).Order("sort_order ASC, id ASC").Find(&groups).Error; err != nil { + return nil, err + } + items := make([]GroupDTO, 0, len(groups)) + for _, group := range groups { + dto, err := r.toDTO(ctx, group) + if err != nil { + return nil, err + } + items = append(items, dto) + } + return items, nil +} + +func (r *Repository) FindByID(ctx context.Context, id uint64) (*GroupDTO, error) { + var group model.ChatSupportGroup + if err := r.db.WithContext(ctx).First(&group, id).Error; err != nil { + return nil, err + } + dto, err := r.toDTO(ctx, group) + if err != nil { + return nil, err + } + return &dto, nil +} + +func (r *Repository) Create(ctx context.Context, req CreateGroupRequest) (*GroupDTO, error) { + code := strings.TrimSpace(req.Code) + if code == "" { + code = makeCustomCode(req.Name) + } + status := normalizeStatus(req.Status) + group := model.ChatSupportGroup{ + Code: code, + Name: strings.TrimSpace(req.Name), + Description: strings.TrimSpace(req.Description), + Status: status, + SortOrder: req.SortOrder, + } + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&group).Error; err != nil { + return err + } + return replaceMembers(tx, group.ID, req.MemberIDs) + }) + if err != nil { + return nil, err + } + return r.FindByID(ctx, group.ID) +} + +func (r *Repository) Update(ctx context.Context, id uint64, req UpdateGroupRequest) (*GroupDTO, error) { + var group model.ChatSupportGroup + db := r.db.WithContext(ctx) + if err := db.First(&group, id).Error; err != nil { + return nil, err + } + group.Name = strings.TrimSpace(req.Name) + group.Description = strings.TrimSpace(req.Description) + group.Status = normalizeStatus(req.Status) + group.SortOrder = req.SortOrder + err := db.Transaction(func(tx *gorm.DB) error { + if err := tx.Save(&group).Error; err != nil { + return err + } + return replaceMembers(tx, group.ID, req.MemberIDs) + }) + if err != nil { + return nil, err + } + return r.FindByID(ctx, id) +} + +func (r *Repository) Delete(ctx context.Context, id uint64) error { + var group model.ChatSupportGroup + db := r.db.WithContext(ctx) + if err := db.First(&group, id).Error; err != nil { + return err + } + if isProtectedCode(group.Code) { + return ErrProtectedGroup + } + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("group_id = ?", id).Delete(&model.ChatSupportGroupMember{}).Error; err != nil { + return err + } + return tx.Delete(&model.ChatSupportGroup{}, id).Error + }) +} + +func (r *Repository) AssignMembers(ctx context.Context, id uint64, memberIDs []uint64) error { + var count int64 + if err := r.db.WithContext(ctx).Model(&model.ChatSupportGroup{}).Where("id = ?", id).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return gorm.ErrRecordNotFound + } + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return replaceMembers(tx, id, memberIDs) + }) +} + +func (r *Repository) ListSupportAdmins(ctx context.Context) ([]MemberDTO, error) { + var rows []struct { + ID uint64 + Username string + Nickname string + SupportStatus string + } + err := r.db.WithContext(ctx).Table("admin_users AS au"). + Select("DISTINCT au.id, au.username, au.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", "cs"). + Order("CASE au.support_status WHEN 'online' THEN 0 WHEN 'busy' THEN 1 ELSE 2 END, au.id ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + items := make([]MemberDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, MemberDTO{ + ID: row.ID, + Username: row.Username, + Nickname: row.Nickname, + SupportStatus: row.SupportStatus, + }) + } + return items, nil +} + +func (r *Repository) toDTO(ctx context.Context, group model.ChatSupportGroup) (GroupDTO, error) { + members, err := r.members(ctx, group.ID) + if err != nil { + return GroupDTO{}, err + } + return GroupDTO{ + ID: group.ID, + Code: group.Code, + Name: group.Name, + Description: group.Description, + Status: group.Status, + SortOrder: group.SortOrder, + Members: members, + CreatedAt: group.CreatedAt, + UpdatedAt: group.UpdatedAt, + }, nil +} + +func (r *Repository) members(ctx context.Context, groupID uint64) ([]MemberDTO, error) { + var rows []struct { + ID uint64 + Username string + Nickname string + SupportStatus string + } + err := r.db.WithContext(ctx).Table("chat_support_group_members AS gm"). + Select("au.id, au.username, au.nickname, au.support_status"). + Joins("JOIN admin_users AS au ON au.id = gm.admin_user_id"). + Where("gm.group_id = ?", groupID). + Order("au.id ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + items := make([]MemberDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, MemberDTO{ + ID: row.ID, + Username: row.Username, + Nickname: row.Nickname, + SupportStatus: row.SupportStatus, + }) + } + return items, nil +} + +func replaceMembers(tx *gorm.DB, groupID uint64, memberIDs []uint64) error { + if err := tx.Where("group_id = ?", groupID).Delete(&model.ChatSupportGroupMember{}).Error; err != nil { + return err + } + for _, adminID := range uniqueIDs(memberIDs) { + if adminID == 0 { + continue + } + member := model.ChatSupportGroupMember{GroupID: groupID, AdminID: adminID} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&member).Error; err != nil { + return err + } + } + return nil +} + +func normalizeStatus(status string) string { + switch status { + case "disabled": + return "disabled" + default: + return "active" + } +} + +func isProtectedCode(code string) bool { + return code == GroupCodeOwnerOnboarding || code == GroupCodeRenterHandoff +} + +func makeCustomCode(name string) string { + base := strings.TrimSpace(strings.ToLower(name)) + base = strings.ReplaceAll(base, " ", "_") + base = strings.ReplaceAll(base, "-", "_") + if base == "" { + base = "custom" + } + return "custom_" + base +} + +func uniqueIDs(ids []uint64) []uint64 { + seen := make(map[uint64]struct{}, len(ids)) + result := make([]uint64, 0, len(ids)) + for _, id := range ids { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} + +func IsNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} diff --git a/backend/internal/modules/supportgroup/selector.go b/backend/internal/modules/supportgroup/selector.go new file mode 100644 index 0000000..f1e63d3 --- /dev/null +++ b/backend/internal/modules/supportgroup/selector.go @@ -0,0 +1,57 @@ +package supportgroup + +import ( + "errors" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +func PickSupportAdmin(tx *gorm.DB, groupCode string) (uint64, error) { + var row struct { + ID uint64 + } + err := tx.Table("chat_support_groups AS g"). + Select("au.id"). + Joins("JOIN chat_support_group_members AS gm ON gm.group_id = g.id"). + Joins("JOIN admin_users AS au ON au.id = gm.admin_user_id"). + Where("g.code = ? AND g.status = ? AND au.status = ?", groupCode, "active", "active"). + Order(`CASE au.support_status WHEN 'online' THEN 0 WHEN 'busy' THEN 1 ELSE 2 END, + ( + SELECT COUNT(1) + FROM chat_participants AS cp + WHERE cp.participant_type = 'admin' + AND cp.role = 'support' + AND cp.participant_id = au.id + ) ASC, + au.id ASC`). + Limit(1). + Scan(&row).Error + if err != nil { + return 0, err + } + if row.ID > 0 { + return row.ID, nil + } + return fallbackSupportAdmin(tx) +} + +func fallbackSupportAdmin(tx *gorm.DB) (uint64, error) { + var admin model.AdminUser + err := tx.Table("admin_users AS au"). + Select("au.*"). + 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", "cs"). + Order("CASE au.support_status WHEN 'online' THEN 0 WHEN 'busy' THEN 1 ELSE 2 END, au.id ASC"). + Limit(1). + First(&admin).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + return 0, err + } + return admin.ID, nil +} diff --git a/backend/internal/modules/supportgroup/service.go b/backend/internal/modules/supportgroup/service.go new file mode 100644 index 0000000..ca296b6 --- /dev/null +++ b/backend/internal/modules/supportgroup/service.go @@ -0,0 +1,77 @@ +package supportgroup + +import ( + "context" + "strings" +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) List(ctx context.Context) ([]GroupDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.List(ctx) +} + +func (s *Service) FindByID(ctx context.Context, id uint64) (*GroupDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.FindByID(ctx, id) +} + +func (s *Service) Create(ctx context.Context, req CreateGroupRequest) (*GroupDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + return nil, ErrInvalidGroup + } + return s.repo.Create(ctx, req) +} + +func (s *Service) Update(ctx context.Context, id uint64, req UpdateGroupRequest) (*GroupDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + req.Name = strings.TrimSpace(req.Name) + if id == 0 || req.Name == "" { + return nil, ErrInvalidGroup + } + return s.repo.Update(ctx, id, req) +} + +func (s *Service) Delete(ctx context.Context, id uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + if id == 0 { + return ErrInvalidGroup + } + return s.repo.Delete(ctx, id) +} + +func (s *Service) AssignMembers(ctx context.Context, id uint64, req AssignMembersRequest) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + if id == 0 { + return ErrInvalidGroup + } + return s.repo.AssignMembers(ctx, id, req.MemberIDs) +} + +func (s *Service) ListSupportAdmins(ctx context.Context) ([]MemberDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListSupportAdmins(ctx) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 4d4d492..a9857ce 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -28,6 +28,7 @@ import ( "hfb_sys/backend/internal/modules/paymentaccount" "hfb_sys/backend/internal/modules/paymentconfig" "hfb_sys/backend/internal/modules/realname" + "hfb_sys/backend/internal/modules/supportgroup" "hfb_sys/backend/internal/modules/systemconfig" "hfb_sys/backend/internal/modules/user" "hfb_sys/backend/internal/modules/wallet" @@ -283,6 +284,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } adminMgrService := adminmgr.NewService(adminMgrRepo) adminMgrHandler := adminmgr.NewHandler(adminMgrService) + var supportGroupRepo *supportgroup.Repository + if deps.DB != nil { + supportGroupRepo = supportgroup.NewRepository(deps.DB) + } + supportGroupService := supportgroup.NewService(supportGroupRepo) + supportGroupHandler := supportgroup.NewHandler(supportGroupService) var fileStorage *filemodule.Storage if cfg.Storage.Endpoint != "" && cfg.Storage.Bucket != "" { var err error @@ -563,6 +570,15 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.PATCH("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.UpdateQrCodeHandler) adminRoutes.DELETE("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.DeleteQrCodeHandler) + // 客服分组 + adminRoutes.GET("/chat-support-groups", requirePerm("chat:manage"), supportGroupHandler.List) + adminRoutes.GET("/chat-support-groups/support-admins", requirePerm("chat:manage"), supportGroupHandler.ListSupportAdmins) + adminRoutes.GET("/chat-support-groups/:id", requirePerm("chat:manage"), supportGroupHandler.FindByID) + adminRoutes.POST("/chat-support-groups", requirePerm("chat:manage"), supportGroupHandler.Create) + adminRoutes.PUT("/chat-support-groups/:id", requirePerm("chat:manage"), supportGroupHandler.Update) + adminRoutes.DELETE("/chat-support-groups/:id", requirePerm("chat:manage"), supportGroupHandler.Delete) + adminRoutes.PUT("/chat-support-groups/:id/members", requirePerm("chat:manage"), supportGroupHandler.AssignMembers) + // 角色管理 adminRoutes.GET("/roles", requirePerm("role:manage"), adminRoleHandler.List) adminRoutes.GET("/roles/:id", requirePerm("role:manage"), adminRoleHandler.FindByID) diff --git a/backend/migrations/000011_chat_support_groups.sql b/backend/migrations/000011_chat_support_groups.sql new file mode 100644 index 0000000..37f2227 --- /dev/null +++ b/backend/migrations/000011_chat_support_groups.sql @@ -0,0 +1,69 @@ +-- +goose Up +-- +goose StatementBegin + +INSERT INTO permissions (code, name, resource, action) VALUES +('chat:manage', '管理客服配置', 'chat', 'manage') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + resource = VALUES(resource), + action = VALUES(action); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.code = 'chat:manage' +WHERE r.code = 'super_admin'; + +CREATE TABLE IF NOT EXISTS chat_support_groups ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + code VARCHAR(64) NOT NULL COMMENT '固定业务编码: owner_onboarding/renter_handoff', + name VARCHAR(64) NOT NULL COMMENT '客服分组名称', + description VARCHAR(255) NOT NULL DEFAULT '' COMMENT '分组说明', + status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态: active/disabled', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_chat_support_groups_code (code), + KEY idx_chat_support_groups_status_sort (status, sort_order, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客服分组表'; + +CREATE TABLE IF NOT EXISTS chat_support_group_members ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + group_id BIGINT UNSIGNED NOT NULL COMMENT '客服分组ID', + admin_user_id BIGINT UNSIGNED NOT NULL COMMENT '管理员ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_support_group_member (group_id, admin_user_id), + KEY idx_support_group_members_admin (admin_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客服分组成员表'; + +INSERT INTO chat_support_groups (code, name, description, status, sort_order) VALUES +('owner_onboarding', '收号组-客服', '负责用户发布后的收号沟通、拉群和入群引导', 'active', 10), +('renter_handoff', '卖号组-客服', '负责租客支付后的交接跟进和售后沟通', 'active', 20) +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 IN ('owner_onboarding', 'renter_handoff') + AND r.code = 'cs' + AND au.status = 'active'; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP TABLE IF EXISTS chat_support_group_members; +DROP TABLE IF EXISTS chat_support_groups; +DELETE rp FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code = 'chat:manage'; +DELETE FROM permissions WHERE code = 'chat:manage'; + +-- +goose StatementEnd diff --git a/frontend/src/features/admin/api/supportGroups.ts b/frontend/src/features/admin/api/supportGroups.ts new file mode 100644 index 0000000..b587f10 --- /dev/null +++ b/frontend/src/features/admin/api/supportGroups.ts @@ -0,0 +1,66 @@ +import { apiClient } from '@/shared/api/client' + +import type { ApiResponse } from '@/shared/types/types' + +export interface SupportGroupMember { + id: number + username: string + nickname: string + support_status: string +} + +export interface SupportGroup { + id: number + code: string + name: string + description: string + status: string + sort_order: number + members: SupportGroupMember[] + created_at: string + updated_at: string +} + +export interface SaveSupportGroupRequest { + code?: string + name: string + description?: string + status?: string + sort_order?: number + member_ids: number[] +} + +export async function fetchSupportGroups() { + const { data } = await apiClient.get>('/admin/chat-support-groups') + return data.data +} + +export async function fetchSupportGroupAdmins() { + const { data } = await apiClient.get>( + '/admin/chat-support-groups/support-admins' + ) + return data.data +} + +export async function createSupportGroup(req: SaveSupportGroupRequest) { + const { data } = await apiClient.post>( + '/admin/chat-support-groups', + req + ) + return data.data +} + +export async function updateSupportGroup(id: number, req: SaveSupportGroupRequest) { + const { data } = await apiClient.put>( + `/admin/chat-support-groups/${id}`, + req + ) + return data.data +} + +export async function deleteSupportGroup(id: number) { + const { data } = await apiClient.delete>( + `/admin/chat-support-groups/${id}` + ) + return data.data +} diff --git a/frontend/src/features/admin/index.ts b/frontend/src/features/admin/index.ts index edf0c00..ad0ee95 100644 --- a/frontend/src/features/admin/index.ts +++ b/frontend/src/features/admin/index.ts @@ -7,6 +7,7 @@ export * from './api/adminPayments' export * from './api/adminFinance' export * from './api/adminAudit' export * from './api/systemConfigs' +export * from './api/supportGroups' export * from './composables/useAdminTable' export * from './composables/useAdminPaginatedTable' diff --git a/frontend/src/features/admin/views/AdminSupportGroupsView.vue b/frontend/src/features/admin/views/AdminSupportGroupsView.vue new file mode 100644 index 0000000..e959387 --- /dev/null +++ b/frontend/src/features/admin/views/AdminSupportGroupsView.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 6b2c284..cb7384a 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -100,6 +100,7 @@ const allNavGroups: NavGroup[] = [ }, { label: '客服群聊', to: adminPath('chats'), icon: ChatDotRound, permission: 'chat:view' }, { label: '二维码池', to: adminPath('qrcode-pool'), icon: Picture, permission: 'chat:view' }, + { label: '客服分组', to: adminPath('support-groups'), icon: User, permission: 'chat:manage' }, ], }, { diff --git a/frontend/src/router/adminRoutes.ts b/frontend/src/router/adminRoutes.ts index db354dc..b56e58e 100644 --- a/frontend/src/router/adminRoutes.ts +++ b/frontend/src/router/adminRoutes.ts @@ -72,6 +72,12 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/features/admin/views/AdminQrCodePoolView.vue'), meta: adminMeta, }, + { + path: adminPath('support-groups'), + name: 'admin-support-groups', + component: () => import('@/features/admin/views/AdminSupportGroupsView.vue'), + meta: adminMeta, + }, { path: adminPath('finance/dashboard'), name: 'admin-finance-dashboard',