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)) 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"`
+2 -2
View File
@@ -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
+1
View File
@@ -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) {
+1 -1
View File
@@ -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
} }
+11 -10
View File
@@ -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 {
+1 -1
View File
@@ -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
} }
+13 -1
View File
@@ -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">
+1
View File
@@ -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 {