feat(channel): 支持安全删除渠道并保留历史数据
This commit is contained in:
@@ -152,6 +152,22 @@ func (h *ChannelHandler) Update(c *gin.Context) {
|
||||
middleware.JSON(c, toChannelView(channel))
|
||||
}
|
||||
|
||||
func (h *ChannelHandler) Delete(c *gin.Context) {
|
||||
if !requireTenantAdmin(c) {
|
||||
return
|
||||
}
|
||||
var channel model.Channel
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", c.Param("id"), middleware.GetTenantID(c)).First(&channel).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "渠道不存在"})
|
||||
return
|
||||
}
|
||||
if err := model.DB.Delete(&channel).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "删除失败"})
|
||||
return
|
||||
}
|
||||
middleware.JSON(c, gin.H{"id": channel.ID, "deleted": true})
|
||||
}
|
||||
|
||||
type createChannelReq struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -145,7 +145,7 @@ func (h *SessionHandler) Export(c *gin.Context) {
|
||||
channelMap := map[uint]model.Channel{}
|
||||
if len(channelIDs) > 0 {
|
||||
var channels []model.Channel
|
||||
model.DB.Where("id IN ?", service.UniqueUint(channelIDs)).Find(&channels)
|
||||
model.DB.Unscoped().Where("id IN ?", service.UniqueUint(channelIDs)).Find(&channels)
|
||||
for _, ch := range channels {
|
||||
channelMap[ch.ID] = ch
|
||||
}
|
||||
@@ -323,7 +323,7 @@ func (h *StatisticsHandler) Export(c *gin.Context) {
|
||||
|
||||
// 渠道
|
||||
var channels []model.Channel
|
||||
model.DB.Where("tenant_id = ?", tenantID).Find(&channels)
|
||||
model.DB.Unscoped().Where("tenant_id = ?", tenantID).Find(&channels)
|
||||
channelTypes := make(map[uint]string)
|
||||
for _, channel := range channels {
|
||||
channelTypes[channel.ID] = channel.Type
|
||||
|
||||
@@ -139,6 +139,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
channels.GET("", channel.List)
|
||||
channels.POST("", middleware.RequirePermission("settings.channel"), channel.Create)
|
||||
channels.PUT("/:id", middleware.RequirePermission("settings.channel"), channel.Update)
|
||||
channels.DELETE("/:id", middleware.RequirePermission("settings.channel"), channel.Delete)
|
||||
|
||||
// 租户系统设置
|
||||
settingsGroup := authRequired.Group("/settings")
|
||||
|
||||
@@ -637,6 +637,67 @@ func TestChannelListAndToggleRequiresAdmin(t *testing.T) {
|
||||
if err := model.DB.First(&updated, channel.ID).Error; err != nil || updated.Status != "disabled" {
|
||||
t.Fatalf("渠道状态未更新: %+v err=%v", updated, err)
|
||||
}
|
||||
|
||||
customer := model.Customer{TenantID: tenant.ID, Name: "历史渠道客户"}
|
||||
if err := model.DB.Create(&customer).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := model.Session{TenantID: tenant.ID, ChannelID: channel.ID, CustomerID: customer.ID, Status: "waiting"}
|
||||
if err := model.DB.Create(&session).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
denyDelete := httptest.NewRecorder()
|
||||
router.ServeHTTP(denyDelete, bearerRequest(t, http.MethodDelete, fmt.Sprintf("/api/channels/%d", channel.ID), nil, agent))
|
||||
if denyDelete.Code != http.StatusForbidden {
|
||||
t.Fatalf("客服删除渠道应 403: %d %s", denyDelete.Code, denyDelete.Body.String())
|
||||
}
|
||||
|
||||
deleteRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(deleteRecorder, bearerRequest(t, http.MethodDelete, fmt.Sprintf("/api/channels/%d", channel.ID), nil, admin))
|
||||
if deleteRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("管理员删除渠道失败: %d %s", deleteRecorder.Code, deleteRecorder.Body.String())
|
||||
}
|
||||
var deleted model.Channel
|
||||
if err := model.DB.Unscoped().First(&deleted, channel.ID).Error; err != nil || !deleted.DeletedAt.Valid {
|
||||
t.Fatalf("渠道应软删除: %+v err=%v", deleted, err)
|
||||
}
|
||||
|
||||
afterDelete := httptest.NewRecorder()
|
||||
router.ServeHTTP(afterDelete, bearerRequest(t, http.MethodGet, "/api/channels", nil, admin))
|
||||
var channelList struct {
|
||||
Data []model.Channel `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(afterDelete.Body.Bytes(), &channelList); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(channelList.Data) != 0 {
|
||||
t.Fatalf("已删除渠道不应出现在列表: %+v", channelList.Data)
|
||||
}
|
||||
|
||||
historyRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(historyRecorder, bearerRequest(t, http.MethodGet, "/api/sessions?page=1&pageSize=10", nil, admin))
|
||||
var history struct {
|
||||
List []struct {
|
||||
ID uint `json:"id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
} `json:"list"`
|
||||
}
|
||||
if err := json.Unmarshal(historyRecorder.Body.Bytes(), &history); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(history.List) != 1 || history.List[0].ChannelName != "网页" {
|
||||
t.Fatalf("历史会话应保留已删除渠道名称: %+v", history.List)
|
||||
}
|
||||
|
||||
oldWidgetReq := httptest.NewRequest(http.MethodPost, "/api/widget/init",
|
||||
bytes.NewBufferString(`{"channel_key":"WK_ch_001","page_url":"https://example.com/"}`))
|
||||
oldWidgetReq.Header.Set("Content-Type", "application/json")
|
||||
oldWidgetRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(oldWidgetRecorder, oldWidgetReq)
|
||||
if oldWidgetRecorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("已删除渠道的旧嵌入代码应失效: %d %s", oldWidgetRecorder.Code, oldWidgetRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleWebChannelsBindToDomains(t *testing.T) {
|
||||
|
||||
@@ -388,7 +388,7 @@ func (h *StatisticsHandler) ChannelDistribution(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var channels []model.Channel
|
||||
if err := model.DB.Where("tenant_id = ?", tenantID).Find(&channels).Error; err != nil {
|
||||
if err := model.DB.Unscoped().Where("tenant_id = ?", tenantID).Find(&channels).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,16 +35,17 @@ type User struct {
|
||||
}
|
||||
|
||||
type Channel struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||
Type string `gorm:"size:30;not null" json:"type"`
|
||||
Name string `gorm:"size:50" json:"name"`
|
||||
SiteDomain string `gorm:"size:255" json:"site_domain"`
|
||||
Status string `gorm:"size:20;default:enabled" json:"status"`
|
||||
Config string `gorm:"type:text" json:"config"`
|
||||
ScriptCode string `gorm:"size:500" json:"script_code"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||
Type string `gorm:"size:30;not null" json:"type"`
|
||||
Name string `gorm:"size:50" json:"name"`
|
||||
SiteDomain string `gorm:"size:255" json:"site_domain"`
|
||||
Status string `gorm:"size:20;default:enabled" json:"status"`
|
||||
Config string `gorm:"type:text" json:"config"`
|
||||
ScriptCode string `gorm:"size:500" json:"script_code"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
|
||||
@@ -202,7 +202,7 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte
|
||||
channelMap := map[uint]model.Channel{}
|
||||
if len(channelIDs) > 0 {
|
||||
var channels []model.Channel
|
||||
model.DB.Where("id IN ?", UniqueUint(channelIDs)).Find(&channels)
|
||||
model.DB.Unscoped().Where("id IN ?", UniqueUint(channelIDs)).Find(&channels)
|
||||
for _, ch := range channels {
|
||||
channelMap[ch.ID] = ch
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Form, message } from 'antd'
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
createChannel, createStaff, deleteStaff,
|
||||
createChannel, createStaff, deleteChannel as deleteChannelApi, deleteStaff,
|
||||
getChannels, getCustomerTags, getStaff, getTenantSettings,
|
||||
updateChannel, updateStaff, updateTenantSettings,
|
||||
type Channel, type CustomerTag, type StaffUser, type TenantSettings, type WelcomeSegment,
|
||||
@@ -288,6 +288,17 @@ const Settings = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteChannel = async (channel: Channel) => {
|
||||
try {
|
||||
await deleteChannelApi(channel.id)
|
||||
setChannels(previous => previous.filter(item => item.id !== channel.id))
|
||||
message.success('渠道已删除,原嵌入代码立即失效')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const addChannelType = async (t: string) => {
|
||||
try {
|
||||
await createChannel({ type: t, name: typeMeta[t].label })
|
||||
@@ -410,6 +421,7 @@ const Settings = () => {
|
||||
ensureWebChannel={addWebChannel}
|
||||
addChannelType={addChannelType}
|
||||
updateChannelMeta={updateChannelMeta}
|
||||
deleteChannel={handleDeleteChannel}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Empty, Form, Input, Modal, Spin, Switch, message } from 'antd'
|
||||
import { CopyOutlined, EditOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { Button, Empty, Form, Input, Modal, Popconfirm, Spin, Switch, message } from 'antd'
|
||||
import { CopyOutlined, DeleteOutlined, EditOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import type { Channel } from '@/services/api'
|
||||
import { typeMeta } from './channelMeta'
|
||||
|
||||
@@ -27,6 +27,7 @@ interface ChannelsSectionProps {
|
||||
ensureWebChannel: () => void
|
||||
addChannelType: (t: string) => void
|
||||
updateChannelMeta: (id: number, data: { name?: string; site_domain?: string }) => Promise<void>
|
||||
deleteChannel: (channel: Channel) => Promise<void>
|
||||
}
|
||||
|
||||
/** 渠道管理:接入渠道列表与嵌入代码 */
|
||||
@@ -39,6 +40,7 @@ const ChannelsSection = ({
|
||||
ensureWebChannel,
|
||||
addChannelType,
|
||||
updateChannelMeta,
|
||||
deleteChannel,
|
||||
}: ChannelsSectionProps) => {
|
||||
const [form] = Form.useForm()
|
||||
const [editing, setEditing] = useState<Channel | null>(null)
|
||||
@@ -108,12 +110,30 @@ const ChannelsSection = ({
|
||||
{enabled ? '已启用' : '未启用'}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
size="small"
|
||||
loading={togglingId === ch.id}
|
||||
onChange={v => toggleChannel(ch, v)}
|
||||
/>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
size="small"
|
||||
loading={togglingId === ch.id}
|
||||
onChange={v => toggleChannel(ch, v)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={`删除「${ch.name || meta.label}」?`}
|
||||
description="删除后原渠道 Key 和嵌入代码立即失效,历史会话仍会保留。"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => deleteChannel(ch)}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
title="删除渠道"
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
{ch.type === 'web' && (
|
||||
<div className="flex items-center gap-2 text-xs mb-2">
|
||||
|
||||
@@ -579,6 +579,7 @@ export const importQuickReplies = (file: File, scope: QuickReplyScope, onConflic
|
||||
export const getChannels = () => get<Channel[]>('/channels')
|
||||
export const createChannel = (data: { type: string; name?: string; site_domain?: string }) => post<Channel>('/channels', data)
|
||||
export const updateChannel = (id: number, data: { name?: string; status?: string; site_domain?: string }) => put<Channel>(`/channels/${id}`, data)
|
||||
export const deleteChannel = (id: number) => del<{ id: number; deleted: boolean }>(`/channels/${id}`)
|
||||
|
||||
// 坐席账号
|
||||
export interface StaffUser {
|
||||
|
||||
Reference in New Issue
Block a user