实现 P1 客户/知识库/对话记录与渠道设置

- 客户管理接通创建、编辑、删除与详情历史会话
- 知识库接通分类/条目 CRUD,按角色控制写权限
- 对话记录增强筛选、客户名、消息与操作时间线
- 新增租户渠道 API,系统设置渠道管理可启用与复制嵌入代码
This commit is contained in:
yml2213
2026-07-15 11:18:01 +08:00
parent 9485c28abd
commit 65014d80de
8 changed files with 1232 additions and 247 deletions
@@ -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())
}
}