diff --git a/server/internal/handler/channel.go b/server/internal/handler/channel.go index 937d64c..cb11147 100644 --- a/server/internal/handler/channel.go +++ b/server/internal/handler/channel.go @@ -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"` diff --git a/server/internal/handler/export.go b/server/internal/handler/export.go index a72f803..eca528c 100644 --- a/server/internal/handler/export.go +++ b/server/internal/handler/export.go @@ -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 diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index e491e6b..afae545 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -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") diff --git a/server/internal/handler/security_integration_test.go b/server/internal/handler/security_integration_test.go index 3383ae9..116a126 100644 --- a/server/internal/handler/security_integration_test.go +++ b/server/internal/handler/security_integration_test.go @@ -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) { diff --git a/server/internal/handler/statistics.go b/server/internal/handler/statistics.go index 85438b5..a844ddf 100644 --- a/server/internal/handler/statistics.go +++ b/server/internal/handler/statistics.go @@ -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 } diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 5c6528e..6115524 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -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 { diff --git a/server/internal/service/session.go b/server/internal/service/session.go index feb12da..7c5e153 100644 --- a/server/internal/service/session.go +++ b/server/internal/service/session.go @@ -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 } diff --git a/web/src/pages/agent/Settings.tsx b/web/src/pages/agent/Settings.tsx index 4ca7f36..2bc9dd4 100644 --- a/web/src/pages/agent/Settings.tsx +++ b/web/src/pages/agent/Settings.tsx @@ -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} /> )} diff --git a/web/src/pages/agent/settings/ChannelsSection.tsx b/web/src/pages/agent/settings/ChannelsSection.tsx index 85ff3a9..50a0b94 100644 --- a/web/src/pages/agent/settings/ChannelsSection.tsx +++ b/web/src/pages/agent/settings/ChannelsSection.tsx @@ -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 + deleteChannel: (channel: Channel) => Promise } /** 渠道管理:接入渠道列表与嵌入代码 */ @@ -39,6 +40,7 @@ const ChannelsSection = ({ ensureWebChannel, addChannelType, updateChannelMeta, + deleteChannel, }: ChannelsSectionProps) => { const [form] = Form.useForm() const [editing, setEditing] = useState(null) @@ -108,12 +110,30 @@ const ChannelsSection = ({ {enabled ? '已启用' : '未启用'} - toggleChannel(ch, v)} - /> +
+ toggleChannel(ch, v)} + /> + deleteChannel(ch)} + > +
{ch.type === 'web' && (
diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 6cee609..8405498 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -579,6 +579,7 @@ export const importQuickReplies = (file: File, scope: QuickReplyScope, onConflic export const getChannels = () => get('/channels') export const createChannel = (data: { type: string; name?: string; site_domain?: string }) => post('/channels', data) export const updateChannel = (id: number, data: { name?: string; status?: string; site_domain?: string }) => put(`/channels/${id}`, data) +export const deleteChannel = (id: number) => del<{ id: number; deleted: boolean }>(`/channels/${id}`) // 坐席账号 export interface StaffUser {