增加客服分组管理
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user