新增客户标签库:管理员维护、员工选择
租户级标签 CRUD 与颜色预设;客户打标仅能从库中选择,设置页提供管理入口,列表筛选随标签库动态生成。
This commit is contained in:
@@ -154,6 +154,16 @@ func seed() {
|
||||
}
|
||||
model.DB.Create(&entries)
|
||||
|
||||
// 客户标签库(管理员维护,坐席选择)
|
||||
customerTags := []model.CustomerTag{
|
||||
{TenantID: tid, Name: "VIP客户", Color: "amber", SortOrder: 0},
|
||||
{TenantID: tid, Name: "新客户", Color: "green", SortOrder: 1},
|
||||
{TenantID: tid, Name: "活跃", Color: "blue", SortOrder: 2},
|
||||
{TenantID: tid, Name: "沉默", Color: "orange", SortOrder: 3},
|
||||
{TenantID: tid, Name: "企业客户", Color: "cyan", SortOrder: 4},
|
||||
}
|
||||
model.DB.Create(&customerTags)
|
||||
|
||||
// 团队快捷回复(独立模块;知识库「快捷回复模板」分类可逐步弃用)
|
||||
quickReplies := []model.QuickReply{
|
||||
{TenantID: tid, Scope: "team", Title: "打招呼", Content: "您好!欢迎来到客服云,请问有什么可以帮您的?", Shortcut: "nh", GroupName: "通用", Status: "published", UsageCount: 120},
|
||||
|
||||
@@ -91,6 +91,16 @@ func (h *CustomerHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
customer.TenantID = middleware.GetTenantID(c)
|
||||
if customer.Tags != "" {
|
||||
normalized, err := normalizeCustomerTagsForTenant(customer.TenantID, customer.Tags, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
customer.Tags = normalized
|
||||
} else {
|
||||
customer.Tags = "[]"
|
||||
}
|
||||
|
||||
if err := model.DB.Create(&customer).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
|
||||
@@ -130,6 +140,14 @@ func (h *CustomerHandler) Update(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
|
||||
return
|
||||
}
|
||||
if raw, ok := updates["tags"]; ok {
|
||||
normalized, err := normalizeCustomerTagsForTenant(tenantID, raw, parseCustomerTagsJSON(customer.Tags))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
updates["tags"] = normalized
|
||||
}
|
||||
|
||||
if err := model.DB.Model(&customer).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"kefu-cloud/server/internal/middleware"
|
||||
"kefu-cloud/server/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
maxCustomerTagName = 30
|
||||
maxCustomerTagCount = 50
|
||||
)
|
||||
|
||||
// 预设颜色键(前端可映射样式);也允许 #RRGGBB
|
||||
var allowedTagColorKeys = map[string]bool{
|
||||
"": true, "amber": true, "green": true, "blue": true,
|
||||
"cyan": true, "violet": true, "rose": true, "slate": true, "orange": true,
|
||||
}
|
||||
|
||||
type CustomerTagHandler struct{}
|
||||
|
||||
func NewCustomerTagHandler() *CustomerTagHandler { return &CustomerTagHandler{} }
|
||||
|
||||
func requireCustomerTagManager(c *gin.Context) bool {
|
||||
// 管理员维护标签库;主管也可维护,便于运营
|
||||
if middleware.HasAnyRole(c, "admin", "supervisor") {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅管理员或主管可管理客户标签"})
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeTagColor(raw string) (string, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return "slate", nil
|
||||
}
|
||||
if strings.HasPrefix(s, "#") {
|
||||
if len(s) != 7 {
|
||||
return "", fmt.Errorf("颜色格式无效,请使用预设色或 #RRGGBB")
|
||||
}
|
||||
for _, ch := range s[1:] {
|
||||
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) {
|
||||
return "", fmt.Errorf("颜色格式无效")
|
||||
}
|
||||
}
|
||||
return strings.ToLower(s), nil
|
||||
}
|
||||
s = strings.ToLower(s)
|
||||
if !allowedTagColorKeys[s] {
|
||||
return "", fmt.Errorf("不支持的颜色预设")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// List GET /api/customer-tags — 全员可读(坐席选标签)
|
||||
func (h *CustomerTagHandler) List(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
var list []model.CustomerTag
|
||||
if err := model.DB.Where("tenant_id = ?", tenantID).
|
||||
Order("sort_order asc, id asc").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "加载标签失败"})
|
||||
return
|
||||
}
|
||||
middleware.JSON(c, list)
|
||||
}
|
||||
|
||||
type customerTagReq struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// Create POST /api/customer-tags
|
||||
func (h *CustomerTagHandler) Create(c *gin.Context) {
|
||||
if !requireCustomerTagManager(c) {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
|
||||
var req customerTagReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "标签名称不能为空"})
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxCustomerTagName {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": fmt.Sprintf("标签名称不能超过 %d 字", maxCustomerTagName)})
|
||||
return
|
||||
}
|
||||
color, err := normalizeTagColor(req.Color)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
model.DB.Model(&model.CustomerTag{}).Where("tenant_id = ?", tenantID).Count(&count)
|
||||
if count >= maxCustomerTagCount {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": fmt.Sprintf("标签最多 %d 个", maxCustomerTagCount)})
|
||||
return
|
||||
}
|
||||
|
||||
var exists int64
|
||||
model.DB.Model(&model.CustomerTag{}).Where("tenant_id = ? AND name = ?", tenantID, name).Count(&exists)
|
||||
if exists > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "标签名称已存在"})
|
||||
return
|
||||
}
|
||||
|
||||
sortOrder := int(count)
|
||||
if req.SortOrder != nil {
|
||||
sortOrder = *req.SortOrder
|
||||
}
|
||||
item := model.CustomerTag{
|
||||
TenantID: tenantID,
|
||||
Name: name,
|
||||
Color: color,
|
||||
SortOrder: sortOrder,
|
||||
}
|
||||
if err := model.DB.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
|
||||
return
|
||||
}
|
||||
middleware.JSON(c, item)
|
||||
}
|
||||
|
||||
// Update PUT /api/customer-tags/:id
|
||||
func (h *CustomerTagHandler) Update(c *gin.Context) {
|
||||
if !requireCustomerTagManager(c) {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if id64 == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
var item model.CustomerTag
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", id64, tenantID).First(&item).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "标签不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var req customerTagReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "标签名称不能为空"})
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxCustomerTagName {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": fmt.Sprintf("标签名称不能超过 %d 字", maxCustomerTagName)})
|
||||
return
|
||||
}
|
||||
color, err := normalizeTagColor(req.Color)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var conflict int64
|
||||
model.DB.Model(&model.CustomerTag{}).
|
||||
Where("tenant_id = ? AND name = ? AND id <> ?", tenantID, name, item.ID).
|
||||
Count(&conflict)
|
||||
if conflict > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "标签名称已存在"})
|
||||
return
|
||||
}
|
||||
|
||||
oldName := item.Name
|
||||
updates := map[string]interface{}{
|
||||
"name": name,
|
||||
"color": color,
|
||||
}
|
||||
if req.SortOrder != nil {
|
||||
updates["sort_order"] = *req.SortOrder
|
||||
}
|
||||
if err := model.DB.Model(&item).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 重命名时同步客户 tags JSON 中的旧名称
|
||||
if oldName != name {
|
||||
syncCustomerTagRename(tenantID, oldName, name)
|
||||
}
|
||||
|
||||
_ = model.DB.First(&item, item.ID)
|
||||
middleware.JSON(c, item)
|
||||
}
|
||||
|
||||
// Delete DELETE /api/customer-tags/:id
|
||||
func (h *CustomerTagHandler) Delete(c *gin.Context) {
|
||||
if !requireCustomerTagManager(c) {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if id64 == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
var item model.CustomerTag
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", id64, tenantID).First(&item).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "标签不存在"})
|
||||
return
|
||||
}
|
||||
if err := model.DB.Delete(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "删除失败"})
|
||||
return
|
||||
}
|
||||
// 从客户 tags 中移除该标签名(不删客户)
|
||||
syncCustomerTagRemove(tenantID, item.Name)
|
||||
middleware.JSON(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func parseCustomerTagsJSON(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(raw), &arr); err == nil {
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, t := range arr {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// 兼容逗号分隔
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func syncCustomerTagRename(tenantID uint, oldName, newName string) {
|
||||
var customers []model.Customer
|
||||
model.DB.Where("tenant_id = ? AND tags LIKE ?", tenantID, "%"+oldName+"%").Find(&customers)
|
||||
for _, cu := range customers {
|
||||
tags := parseCustomerTagsJSON(cu.Tags)
|
||||
changed := false
|
||||
for i, t := range tags {
|
||||
if t == oldName {
|
||||
tags[i] = newName
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
// 去重
|
||||
tags = uniqueStrings(tags)
|
||||
b, _ := json.Marshal(tags)
|
||||
model.DB.Model(&model.Customer{}).Where("id = ?", cu.ID).Update("tags", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func syncCustomerTagRemove(tenantID uint, name string) {
|
||||
var customers []model.Customer
|
||||
model.DB.Where("tenant_id = ? AND tags LIKE ?", tenantID, "%"+name+"%").Find(&customers)
|
||||
for _, cu := range customers {
|
||||
tags := parseCustomerTagsJSON(cu.Tags)
|
||||
next := make([]string, 0, len(tags))
|
||||
changed := false
|
||||
for _, t := range tags {
|
||||
if t == name {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
next = append(next, t)
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
b, _ := json.Marshal(next)
|
||||
model.DB.Model(&model.Customer{}).Where("id = ?", cu.ID).Update("tags", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueStrings(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeCustomerTagsForTenant 校验并规范化客户 tags。
|
||||
// 新建:必须来自标签库;更新时允许保留客户已有的历史标签(库中已删/改名前的孤儿)。
|
||||
func normalizeCustomerTagsForTenant(tenantID uint, raw interface{}, existingOnCustomer []string) (string, error) {
|
||||
var names []string
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
names = parseCustomerTagsJSON(v)
|
||||
case []interface{}:
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
for _, s := range v {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
case nil:
|
||||
names = nil
|
||||
default:
|
||||
return "", fmt.Errorf("标签格式无效")
|
||||
}
|
||||
names = uniqueStrings(names)
|
||||
if len(names) > 10 {
|
||||
return "", fmt.Errorf("每位客户最多 10 个标签")
|
||||
}
|
||||
if len(names) == 0 {
|
||||
b, _ := json.Marshal([]string{})
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
var catalog []model.CustomerTag
|
||||
model.DB.Where("tenant_id = ?", tenantID).Find(&catalog)
|
||||
allowed := map[string]bool{}
|
||||
for _, t := range catalog {
|
||||
allowed[t.Name] = true
|
||||
}
|
||||
legacy := map[string]bool{}
|
||||
for _, t := range existingOnCustomer {
|
||||
legacy[t] = true
|
||||
}
|
||||
if len(allowed) == 0 && len(legacy) == 0 {
|
||||
return "", fmt.Errorf("请先在系统设置中创建客户标签")
|
||||
}
|
||||
for _, n := range names {
|
||||
if allowed[n] || legacy[n] {
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("标签「%s」不在可选列表中,请从标签库选择", n)
|
||||
}
|
||||
b, _ := json.Marshal(names)
|
||||
return string(b), nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeTagColor(t *testing.T) {
|
||||
c, err := normalizeTagColor("")
|
||||
if err != nil || c != "slate" {
|
||||
t.Fatalf("empty -> slate, got %q %v", c, err)
|
||||
}
|
||||
c, err = normalizeTagColor("Blue")
|
||||
if err != nil || c != "blue" {
|
||||
t.Fatalf("blue preset, got %q %v", c, err)
|
||||
}
|
||||
c, err = normalizeTagColor("#A1b2C3")
|
||||
if err != nil || c != "#a1b2c3" {
|
||||
t.Fatalf("hex, got %q %v", c, err)
|
||||
}
|
||||
if _, err := normalizeTagColor("nope"); err == nil {
|
||||
t.Fatal("invalid preset should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCustomerTagsJSON(t *testing.T) {
|
||||
got := parseCustomerTagsJSON(`["VIP客户","新客户"]`)
|
||||
if len(got) != 2 || got[0] != "VIP客户" {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
got = parseCustomerTagsJSON("活跃, 沉默")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
auth := NewAuthHandler()
|
||||
session := NewSessionHandler()
|
||||
customer := NewCustomerHandler()
|
||||
customerTag := NewCustomerTagHandler()
|
||||
knowledge := NewKnowledgeHandler()
|
||||
quickReply := NewQuickReplyHandler()
|
||||
stats := NewStatisticsHandler()
|
||||
@@ -77,6 +78,13 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
customers.PUT("/:id", customer.Update)
|
||||
customers.DELETE("/:id", customer.Delete)
|
||||
|
||||
// 客户标签库(管理员维护,全员可读可选)
|
||||
ctags := authRequired.Group("/customer-tags")
|
||||
ctags.GET("", customerTag.List)
|
||||
ctags.POST("", customerTag.Create)
|
||||
ctags.PUT("/:id", customerTag.Update)
|
||||
ctags.DELETE("/:id", customerTag.Delete)
|
||||
|
||||
// 知识库
|
||||
kb := authRequired.Group("/knowledge")
|
||||
kb.GET("/categories", knowledge.ListCategories)
|
||||
|
||||
@@ -635,6 +635,16 @@ func TestCustomerAndKnowledgeCRUD(t *testing.T) {
|
||||
tenant := createTenant(t, "业务CRUD租户", "normal")
|
||||
admin := createUser(t, tenant.ID, "biz-admin", "admin")
|
||||
|
||||
// 先建标签库,客户打标只能从库中选
|
||||
for _, name := range []string{"新客户", "VIP客户", "活跃"} {
|
||||
tagRec := httptest.NewRecorder()
|
||||
body := fmt.Sprintf(`{"name":%q,"color":"blue"}`, name)
|
||||
router.ServeHTTP(tagRec, bearerRequest(t, http.MethodPost, "/api/customer-tags", []byte(body), admin))
|
||||
if tagRec.Code != http.StatusOK {
|
||||
t.Fatalf("创建标签 %s 失败: %s", name, tagRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
createCustomerRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createCustomerRec, bearerRequest(t, http.MethodPost, "/api/customers", []byte(`{"name":"测试客户甲","phone":"13900001111","tags":"[\"新客户\"]","status":"offline","source":"手动"}`), admin))
|
||||
if createCustomerRec.Code != http.StatusOK {
|
||||
|
||||
@@ -33,6 +33,7 @@ func Migrate(db *gorm.DB) error {
|
||||
&User{},
|
||||
&Channel{},
|
||||
&Customer{},
|
||||
&CustomerTag{},
|
||||
&Session{},
|
||||
&Message{},
|
||||
&SessionEvent{},
|
||||
|
||||
@@ -63,6 +63,17 @@ type Customer struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CustomerTag 租户级客户标签库,由管理员维护,坐席给客户打标时选择。
|
||||
type CustomerTag struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TenantID uint `gorm:"uniqueIndex:idx_tenant_tag_name;not null" json:"tenant_id"`
|
||||
Name string `gorm:"size:30;uniqueIndex:idx_tenant_tag_name;not null" json:"name"`
|
||||
Color string `gorm:"size:20" json:"color"` // 预设色键或 #hex
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||
|
||||
Reference in New Issue
Block a user