增加客服分组管理

This commit is contained in:
yml
2026-06-18 02:09:02 +08:00
parent 2223665b14
commit 233920025e
16 changed files with 1082 additions and 20 deletions
+33 -5
View File
@@ -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)
}
+12 -15
View File
@@ -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
}
// 添加系统消息记录转接
@@ -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"`
}
@@ -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")
)
@@ -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", "客服分组操作失败")
}
}
@@ -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)
}
@@ -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
}
@@ -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)
}