新增客户标签库:管理员维护、员工选择

租户级标签 CRUD 与颜色预设;客户打标仅能从库中选择,设置页提供管理入口,列表筛选随标签库动态生成。
This commit is contained in:
yml2213
2026-07-17 22:28:16 +08:00
parent c5f28dca1c
commit 7a08f5f729
11 changed files with 723 additions and 23 deletions
+18
View File
@@ -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": "更新失败"})
+377
View File
@@ -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)
}
}
+8
View File
@@ -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 {