实现 P1 客户/知识库/对话记录与渠道设置
- 客户管理接通创建、编辑、删除与详情历史会话 - 知识库接通分类/条目 CRUD,按角色控制写权限 - 对话记录增强筛选、客户名、消息与操作时间线 - 新增租户渠道 API,系统设置渠道管理可启用与复制嵌入代码
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"kefu-sys/server/internal/middleware"
|
||||
"kefu-sys/server/internal/model"
|
||||
)
|
||||
|
||||
type ChannelHandler struct{}
|
||||
|
||||
func NewChannelHandler() *ChannelHandler { return &ChannelHandler{} }
|
||||
|
||||
var scriptIDPattern = regexp.MustCompile(`data-id="([A-Za-z0-9_-]+)"`)
|
||||
|
||||
type channelView struct {
|
||||
ID uint `json:"id"`
|
||||
TenantID uint `json:"tenant_id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Config string `json:"config"`
|
||||
ScriptCode string `json:"script_code"`
|
||||
ChannelKey string `json:"channel_key"`
|
||||
}
|
||||
|
||||
func extractChannelKey(script string) string {
|
||||
m := scriptIDPattern.FindStringSubmatch(script)
|
||||
if len(m) == 2 {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildWebScript(channelKey string) string {
|
||||
return fmt.Sprintf(`<script src="/widget.js" data-id="%s"></script>`, channelKey)
|
||||
}
|
||||
|
||||
func newChannelKey(prefix string) (string, error) {
|
||||
buf := make([]byte, 4)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s_%s", prefix, hex.EncodeToString(buf)), nil
|
||||
}
|
||||
|
||||
func toChannelView(ch model.Channel) channelView {
|
||||
return channelView{
|
||||
ID: ch.ID, TenantID: ch.TenantID, Type: ch.Type, Name: ch.Name,
|
||||
Status: ch.Status, Config: ch.Config, ScriptCode: ch.ScriptCode,
|
||||
ChannelKey: extractChannelKey(ch.ScriptCode),
|
||||
}
|
||||
}
|
||||
|
||||
func requireTenantAdmin(c *gin.Context) bool {
|
||||
if middleware.HasAnyRole(c, "admin") {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可操作"})
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *ChannelHandler) List(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
var channels []model.Channel
|
||||
if err := model.DB.Where("tenant_id = ?", tenantID).Order("id asc").Find(&channels).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道失败"})
|
||||
return
|
||||
}
|
||||
views := make([]channelView, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
views = append(views, toChannelView(ch))
|
||||
}
|
||||
middleware.JSON(c, views)
|
||||
}
|
||||
|
||||
type updateChannelReq struct {
|
||||
Name *string `json:"name"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
|
||||
func (h *ChannelHandler) Update(c *gin.Context) {
|
||||
if !requireTenantAdmin(c) {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
var channel model.Channel
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&channel).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "渠道不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var req updateChannelReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if name == "" || len([]rune(name)) > 50 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "渠道名称无效"})
|
||||
return
|
||||
}
|
||||
updates["name"] = name
|
||||
}
|
||||
if req.Status != nil {
|
||||
status := strings.TrimSpace(*req.Status)
|
||||
if status != "enabled" && status != "disabled" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "状态仅支持 enabled/disabled"})
|
||||
return
|
||||
}
|
||||
updates["status"] = status
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.DB.Model(&channel).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
|
||||
return
|
||||
}
|
||||
model.DB.First(&channel, channel.ID)
|
||||
middleware.JSON(c, toChannelView(channel))
|
||||
}
|
||||
|
||||
type createChannelReq struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (h *ChannelHandler) Create(c *gin.Context) {
|
||||
if !requireTenantAdmin(c) {
|
||||
return
|
||||
}
|
||||
var req createChannelReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
channelType := strings.TrimSpace(req.Type)
|
||||
allowed := map[string]string{
|
||||
"web": "网页聊天", "wechat": "微信公众号", "app": "APP 内嵌",
|
||||
"phone": "电话客服", "email": "邮件工单",
|
||||
}
|
||||
defaultName, ok := allowed[channelType]
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不支持的渠道类型"})
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
var exists int64
|
||||
model.DB.Model(&model.Channel{}).Where("tenant_id = ? AND type = ?", tenantID, channelType).Count(&exists)
|
||||
if exists > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "该类型渠道已存在"})
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = defaultName
|
||||
}
|
||||
prefix := map[string]string{"web": "WK", "wechat": "WX", "app": "AP", "phone": "PH", "email": "EM"}[channelType]
|
||||
key, err := newChannelKey(prefix)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "生成渠道标识失败"})
|
||||
return
|
||||
}
|
||||
|
||||
channel := model.Channel{
|
||||
TenantID: tenantID,
|
||||
Type: channelType,
|
||||
Name: name,
|
||||
Status: "disabled",
|
||||
}
|
||||
if channelType == "web" {
|
||||
channel.Status = "enabled"
|
||||
channel.ScriptCode = buildWebScript(key)
|
||||
} else {
|
||||
channel.ScriptCode = fmt.Sprintf(`data-id="%s"`, key)
|
||||
}
|
||||
if err := model.DB.Create(&channel).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
|
||||
return
|
||||
}
|
||||
middleware.JSON(c, toChannelView(channel))
|
||||
}
|
||||
@@ -12,6 +12,7 @@ func SetupRoutes(r *gin.Engine) {
|
||||
knowledge := NewKnowledgeHandler()
|
||||
stats := NewStatisticsHandler()
|
||||
admin := NewAdminHandler()
|
||||
channel := NewChannelHandler()
|
||||
ws := NewWsHandler()
|
||||
widget := NewWidgetHandler()
|
||||
|
||||
@@ -68,6 +69,12 @@ func SetupRoutes(r *gin.Engine) {
|
||||
kb.PUT("/entries/:id", knowledge.UpdateEntry)
|
||||
kb.DELETE("/entries/:id", knowledge.DeleteEntry)
|
||||
|
||||
// 渠道设置(租户级)
|
||||
channels := authRequired.Group("/channels")
|
||||
channels.GET("", channel.List)
|
||||
channels.POST("", channel.Create)
|
||||
channels.PUT("/:id", channel.Update)
|
||||
|
||||
// 统计
|
||||
statistics := authRequired.Group("/statistics")
|
||||
statistics.GET("/kpi", stats.KPIs)
|
||||
|
||||
@@ -540,3 +540,86 @@ func TestWidgetAutoAssignAndOfflineLeave(t *testing.T) {
|
||||
t.Fatalf("未记录自动分配事件: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelListAndToggleRequiresAdmin(t *testing.T) {
|
||||
router := setupRouter(t)
|
||||
tenant := createTenant(t, "渠道租户", "normal")
|
||||
admin := createUser(t, tenant.ID, "ch-admin", "admin")
|
||||
agent := createUser(t, tenant.ID, "ch-agent", "agent")
|
||||
channel := model.Channel{
|
||||
TenantID: tenant.ID, Type: "web", Name: "网页", Status: "enabled",
|
||||
ScriptCode: `<script src="/widget.js" data-id="WK_ch_001"></script>`,
|
||||
}
|
||||
if err := model.DB.Create(&channel).Error; err != nil {
|
||||
t.Fatalf("创建渠道失败: %v", err)
|
||||
}
|
||||
|
||||
listRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(listRecorder, bearerRequest(t, http.MethodGet, "/api/channels", nil, agent))
|
||||
if listRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("客服查看渠道列表失败: %s", listRecorder.Body.String())
|
||||
}
|
||||
|
||||
denyRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(denyRecorder, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/channels/%d", channel.ID), []byte(`{"status":"disabled"}`), agent))
|
||||
if denyRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("客服禁用渠道应 403,实际 %d %s", denyRecorder.Code, denyRecorder.Body.String())
|
||||
}
|
||||
|
||||
okRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(okRecorder, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/channels/%d", channel.ID), []byte(`{"status":"disabled"}`), admin))
|
||||
if okRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("管理员禁用渠道失败: %s", okRecorder.Body.String())
|
||||
}
|
||||
var updated model.Channel
|
||||
if err := model.DB.First(&updated, channel.ID).Error; err != nil || updated.Status != "disabled" {
|
||||
t.Fatalf("渠道状态未更新: %+v err=%v", updated, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerAndKnowledgeCRUD(t *testing.T) {
|
||||
router := setupRouter(t)
|
||||
tenant := createTenant(t, "业务CRUD租户", "normal")
|
||||
admin := createUser(t, tenant.ID, "biz-admin", "admin")
|
||||
|
||||
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 {
|
||||
t.Fatalf("创建客户失败: %s", createCustomerRec.Body.String())
|
||||
}
|
||||
var createCustomerResp struct {
|
||||
Data struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(createCustomerRec.Body.Bytes(), &createCustomerResp); err != nil || createCustomerResp.Data.ID == 0 {
|
||||
t.Fatalf("解析客户创建响应失败: %v body=%s", err, createCustomerRec.Body.String())
|
||||
}
|
||||
|
||||
updateCustomerRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(updateCustomerRec, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/customers/%d", createCustomerResp.Data.ID), []byte(`{"tags":"[\"VIP客户\",\"活跃\"]"}`), admin))
|
||||
if updateCustomerRec.Code != http.StatusOK {
|
||||
t.Fatalf("更新客户失败: %s", updateCustomerRec.Body.String())
|
||||
}
|
||||
|
||||
createCatRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createCatRec, bearerRequest(t, http.MethodPost, "/api/knowledge/categories", []byte(`{"name":"产品FAQ"}`), admin))
|
||||
if createCatRec.Code != http.StatusOK {
|
||||
t.Fatalf("创建分类失败: %s", createCatRec.Body.String())
|
||||
}
|
||||
var catResp struct {
|
||||
Data struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(createCatRec.Body.Bytes(), &catResp); err != nil || catResp.Data.ID == 0 {
|
||||
t.Fatalf("解析分类响应失败: %v", err)
|
||||
}
|
||||
|
||||
createEntryRec := httptest.NewRecorder()
|
||||
body := fmt.Sprintf(`{"title":"如何退货","content":"7天无理由退货","category_id":%d,"status":"published"}`, catResp.Data.ID)
|
||||
router.ServeHTTP(createEntryRec, bearerRequest(t, http.MethodPost, "/api/knowledge/entries", []byte(body), admin))
|
||||
if createEntryRec.Code != http.StatusOK {
|
||||
t.Fatalf("创建知识条目失败: %s", createEntryRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user