feat(channel): 支持安全删除渠道并保留历史数据
CI / Frontend (typecheck + lint + build) (push) Successful in 5m31s
CI / Backend (go vet + test) (push) Failing after 11m10s

This commit is contained in:
yml2213
2026-11-06 18:36:07 +08:00
parent 6297ea6634
commit de1ffbf4f6
10 changed files with 135 additions and 23 deletions
+16
View File
@@ -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"`
+2 -2
View File
@@ -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
+1
View File
@@ -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) {
+1 -1
View File
@@ -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
}
+11 -10
View File
@@ -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 {
+1 -1
View File
@@ -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
}
+13 -1
View File
@@ -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">
+1
View File
@@ -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 {