feat(channel): 支持安全删除渠道并保留历史数据
This commit is contained in:
@@ -152,6 +152,22 @@ func (h *ChannelHandler) Update(c *gin.Context) {
|
|||||||
middleware.JSON(c, toChannelView(channel))
|
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 createChannelReq struct {
|
||||||
Type string `json:"type" binding:"required"`
|
Type string `json:"type" binding:"required"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ func (h *SessionHandler) Export(c *gin.Context) {
|
|||||||
channelMap := map[uint]model.Channel{}
|
channelMap := map[uint]model.Channel{}
|
||||||
if len(channelIDs) > 0 {
|
if len(channelIDs) > 0 {
|
||||||
var channels []model.Channel
|
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 {
|
for _, ch := range channels {
|
||||||
channelMap[ch.ID] = ch
|
channelMap[ch.ID] = ch
|
||||||
}
|
}
|
||||||
@@ -323,7 +323,7 @@ func (h *StatisticsHandler) Export(c *gin.Context) {
|
|||||||
|
|
||||||
// 渠道
|
// 渠道
|
||||||
var channels []model.Channel
|
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)
|
channelTypes := make(map[uint]string)
|
||||||
for _, channel := range channels {
|
for _, channel := range channels {
|
||||||
channelTypes[channel.ID] = channel.Type
|
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.GET("", channel.List)
|
||||||
channels.POST("", middleware.RequirePermission("settings.channel"), channel.Create)
|
channels.POST("", middleware.RequirePermission("settings.channel"), channel.Create)
|
||||||
channels.PUT("/:id", middleware.RequirePermission("settings.channel"), channel.Update)
|
channels.PUT("/:id", middleware.RequirePermission("settings.channel"), channel.Update)
|
||||||
|
channels.DELETE("/:id", middleware.RequirePermission("settings.channel"), channel.Delete)
|
||||||
|
|
||||||
// 租户系统设置
|
// 租户系统设置
|
||||||
settingsGroup := authRequired.Group("/settings")
|
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" {
|
if err := model.DB.First(&updated, channel.ID).Error; err != nil || updated.Status != "disabled" {
|
||||||
t.Fatalf("渠道状态未更新: %+v err=%v", updated, err)
|
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) {
|
func TestMultipleWebChannelsBindToDomains(t *testing.T) {
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ func (h *StatisticsHandler) ChannelDistribution(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var channels []model.Channel
|
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": "查询渠道分布失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,16 +35,17 @@ type User struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Channel struct {
|
type Channel struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||||
Type string `gorm:"size:30;not null" json:"type"`
|
Type string `gorm:"size:30;not null" json:"type"`
|
||||||
Name string `gorm:"size:50" json:"name"`
|
Name string `gorm:"size:50" json:"name"`
|
||||||
SiteDomain string `gorm:"size:255" json:"site_domain"`
|
SiteDomain string `gorm:"size:255" json:"site_domain"`
|
||||||
Status string `gorm:"size:20;default:enabled" json:"status"`
|
Status string `gorm:"size:20;default:enabled" json:"status"`
|
||||||
Config string `gorm:"type:text" json:"config"`
|
Config string `gorm:"type:text" json:"config"`
|
||||||
ScriptCode string `gorm:"size:500" json:"script_code"`
|
ScriptCode string `gorm:"size:500" json:"script_code"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Customer struct {
|
type Customer struct {
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte
|
|||||||
channelMap := map[uint]model.Channel{}
|
channelMap := map[uint]model.Channel{}
|
||||||
if len(channelIDs) > 0 {
|
if len(channelIDs) > 0 {
|
||||||
var channels []model.Channel
|
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 {
|
for _, ch := range channels {
|
||||||
channelMap[ch.ID] = ch
|
channelMap[ch.ID] = ch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { Button, Form, message } from 'antd'
|
import { Button, Form, message } from 'antd'
|
||||||
import { PlusOutlined } from '@ant-design/icons'
|
import { PlusOutlined } from '@ant-design/icons'
|
||||||
import {
|
import {
|
||||||
createChannel, createStaff, deleteStaff,
|
createChannel, createStaff, deleteChannel as deleteChannelApi, deleteStaff,
|
||||||
getChannels, getCustomerTags, getStaff, getTenantSettings,
|
getChannels, getCustomerTags, getStaff, getTenantSettings,
|
||||||
updateChannel, updateStaff, updateTenantSettings,
|
updateChannel, updateStaff, updateTenantSettings,
|
||||||
type Channel, type CustomerTag, type StaffUser, type TenantSettings, type WelcomeSegment,
|
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) => {
|
const addChannelType = async (t: string) => {
|
||||||
try {
|
try {
|
||||||
await createChannel({ type: t, name: typeMeta[t].label })
|
await createChannel({ type: t, name: typeMeta[t].label })
|
||||||
@@ -410,6 +421,7 @@ const Settings = () => {
|
|||||||
ensureWebChannel={addWebChannel}
|
ensureWebChannel={addWebChannel}
|
||||||
addChannelType={addChannelType}
|
addChannelType={addChannelType}
|
||||||
updateChannelMeta={updateChannelMeta}
|
updateChannelMeta={updateChannelMeta}
|
||||||
|
deleteChannel={handleDeleteChannel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Button, Empty, Form, Input, Modal, Spin, Switch, message } from 'antd'
|
import { Button, Empty, Form, Input, Modal, Popconfirm, Spin, Switch, message } from 'antd'
|
||||||
import { CopyOutlined, EditOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons'
|
import { CopyOutlined, DeleteOutlined, EditOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons'
|
||||||
import type { Channel } from '@/services/api'
|
import type { Channel } from '@/services/api'
|
||||||
import { typeMeta } from './channelMeta'
|
import { typeMeta } from './channelMeta'
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ interface ChannelsSectionProps {
|
|||||||
ensureWebChannel: () => void
|
ensureWebChannel: () => void
|
||||||
addChannelType: (t: string) => void
|
addChannelType: (t: string) => void
|
||||||
updateChannelMeta: (id: number, data: { name?: string; site_domain?: string }) => Promise<void>
|
updateChannelMeta: (id: number, data: { name?: string; site_domain?: string }) => Promise<void>
|
||||||
|
deleteChannel: (channel: Channel) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 渠道管理:接入渠道列表与嵌入代码 */
|
/** 渠道管理:接入渠道列表与嵌入代码 */
|
||||||
@@ -39,6 +40,7 @@ const ChannelsSection = ({
|
|||||||
ensureWebChannel,
|
ensureWebChannel,
|
||||||
addChannelType,
|
addChannelType,
|
||||||
updateChannelMeta,
|
updateChannelMeta,
|
||||||
|
deleteChannel,
|
||||||
}: ChannelsSectionProps) => {
|
}: ChannelsSectionProps) => {
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
const [editing, setEditing] = useState<Channel | null>(null)
|
const [editing, setEditing] = useState<Channel | null>(null)
|
||||||
@@ -108,12 +110,30 @@ const ChannelsSection = ({
|
|||||||
{enabled ? '已启用' : '未启用'}
|
{enabled ? '已启用' : '未启用'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
checked={enabled}
|
<Switch
|
||||||
size="small"
|
checked={enabled}
|
||||||
loading={togglingId === ch.id}
|
size="small"
|
||||||
onChange={v => toggleChannel(ch, v)}
|
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>
|
</div>
|
||||||
{ch.type === 'web' && (
|
{ch.type === 'web' && (
|
||||||
<div className="flex items-center gap-2 text-xs mb-2">
|
<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 getChannels = () => get<Channel[]>('/channels')
|
||||||
export const createChannel = (data: { type: string; name?: string; site_domain?: string }) => post<Channel>('/channels', data)
|
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 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 {
|
export interface StaffUser {
|
||||||
|
|||||||
Reference in New Issue
Block a user