新增访客黑名单:支持拉黑 IP/设备并管理列表

工作台可按 IP 或设备拉黑并填写释放时间与原因;侧栏黑名单页查看与解除;进线自动拦截。
This commit is contained in:
yml2213
2026-07-19 00:43:55 +08:00
parent ada7533ffa
commit 1f92c35963
11 changed files with 838 additions and 5 deletions
+348
View File
@@ -0,0 +1,348 @@
package handler
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"kefu-cloud/server/internal/middleware"
"kefu-cloud/server/internal/model"
"kefu-cloud/server/internal/ws"
)
type BlacklistHandler struct{}
func NewBlacklistHandler() *BlacklistHandler { return &BlacklistHandler{} }
// normalizeDeviceKey 规范化设备指纹:优先客户端 device_id;否则用 UA 摘要哈希兜底。
func normalizeDeviceKey(deviceID, userAgent string) string {
id := strings.TrimSpace(deviceID)
if id != "" {
if utf8.RuneCountInString(id) > 64 {
id = string([]rune(id)[:64])
}
return id
}
ua := strings.TrimSpace(userAgent)
if ua == "" {
return ""
}
sum := sha256.Sum256([]byte(strings.ToLower(ua)))
return hex.EncodeToString(sum[:])[:32]
}
// isBlacklisted 检查租户下 IP/设备是否在有效黑名单中。
func isBlacklisted(tenantID uint, kind, value string) (bool, *model.BlacklistEntry) {
value = strings.TrimSpace(value)
if value == "" || (kind != "ip" && kind != "device") {
return false, nil
}
now := time.Now()
var entry model.BlacklistEntry
err := model.DB.
Where("tenant_id = ? AND kind = ? AND value = ?", tenantID, kind, value).
Where("expires_at IS NULL OR expires_at > ?", now).
Order("id desc").
First(&entry).Error
if err != nil {
return false, nil
}
return true, &entry
}
// checkVisitorBlacklist 同时检查 IP 与设备。
func checkVisitorBlacklist(tenantID uint, ip, deviceKey string) (blocked bool, kind string, reason string) {
if ok, e := isBlacklisted(tenantID, "ip", ip); ok {
return true, "ip", e.Reason
}
if ok, e := isBlacklisted(tenantID, "device", deviceKey); ok {
return true, "device", e.Reason
}
return false, "", ""
}
type CreateBlacklistReq struct {
// SessionID 从会话一键拉黑时必填(自动带出 IP/设备)
SessionID *uint `json:"session_id"`
// Kind ip | device
Kind string `json:"kind" binding:"required"`
// Value 手动指定时使用;有 session_id 时可省略
Value string `json:"value"`
// Duration 释放时长:1h | 1d | 7d | 30d | permanent
Duration string `json:"duration" binding:"required"`
Reason string `json:"reason" binding:"required"`
// EndSession 拉黑后是否结束当前会话
EndSession bool `json:"end_session"`
}
func parseBlacklistDuration(duration string) (*time.Time, error) {
duration = strings.TrimSpace(strings.ToLower(duration))
if duration == "permanent" || duration == "forever" || duration == "长期" {
return nil, nil
}
now := time.Now()
var exp time.Time
switch duration {
case "1h", "1hour":
exp = now.Add(time.Hour)
case "6h":
exp = now.Add(6 * time.Hour)
case "1d", "1day", "24h":
exp = now.Add(24 * time.Hour)
case "7d", "7day":
exp = now.Add(7 * 24 * time.Hour)
case "30d", "30day":
exp = now.Add(30 * 24 * time.Hour)
default:
return nil, errInvalidDuration
}
return &exp, nil
}
var errInvalidDuration = errStr("释放时间无效")
type errStr string
func (e errStr) Error() string { return string(e) }
func durationLabel(duration string) string {
switch strings.TrimSpace(strings.ToLower(duration)) {
case "1h", "1hour":
return "1 小时"
case "6h":
return "6 小时"
case "1d", "1day", "24h":
return "1 天"
case "7d", "7day":
return "7 天"
case "30d", "30day":
return "30 天"
case "permanent", "forever", "长期":
return "长期"
default:
return duration
}
}
// Create 创建黑名单(支持从会话拉黑 IP 或设备)。
func (h *BlacklistHandler) Create(c *gin.Context) {
tenantID := middleware.GetTenantID(c)
var req CreateBlacklistReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
kind := strings.TrimSpace(req.Kind)
if kind != "ip" && kind != "device" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "类型仅支持 ip 或 device"})
return
}
reason := strings.TrimSpace(req.Reason)
if reason == "" || utf8.RuneCountInString(reason) > 200 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请填写拉黑原因(1-200 字)"})
return
}
expiresAt, err := parseBlacklistDuration(req.Duration)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "释放时间无效,可选 1h/6h/1d/7d/30d/permanent"})
return
}
var session *model.Session
value := strings.TrimSpace(req.Value)
var sessionID *uint
var customerID *uint
if req.SessionID != nil && *req.SessionID > 0 {
s, ok := loadTenantSession(c, fmt.Sprintf("%d", *req.SessionID))
if !ok {
return
}
// 管理员/主管,或当前接待坐席可拉黑
if !isTenantManager(c) {
if s.AgentID == nil || *s.AgentID != middleware.GetUserID(c) {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅接待坐席或管理员可拉黑该访客"})
return
}
}
session = s
sid := s.ID
sessionID = &sid
cid := s.CustomerID
customerID = &cid
if kind == "ip" {
value = strings.TrimSpace(s.VisitorIP)
if value == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "该会话无有效 IP,无法拉黑"})
return
}
} else {
value = strings.TrimSpace(s.DeviceKey)
if value == "" {
// 旧会话兜底:用 UA 哈希
value = normalizeDeviceKey("", s.UserAgent)
}
if value == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "该会话无设备标识,无法按设备拉黑"})
return
}
}
}
if value == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "缺少拉黑目标"})
return
}
if utf8.RuneCountInString(value) > 200 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "目标值过长"})
return
}
// 已有有效记录则更新原因/到期时间(幂等);否则新建
now := time.Now()
operatorID := middleware.GetUserID(c)
var existing model.BlacklistEntry
found := model.DB.
Where("tenant_id = ? AND kind = ? AND value = ?", tenantID, kind, value).
Where("expires_at IS NULL OR expires_at > ?", now).
Order("id desc").
First(&existing).Error == nil
if found {
// map 更新可写 nil expires_at(长期)
if err := model.DB.Model(&existing).Updates(map[string]interface{}{
"reason": reason,
"expires_at": expiresAt,
"operator_id": operatorID,
"session_id": sessionID,
"customer_id": customerID,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新黑名单失败"})
return
}
// GORM Updates 对 nil pointer 有时跳过;长期时强制写 NULL
if expiresAt == nil {
_ = model.DB.Model(&existing).Update("expires_at", nil).Error
}
model.DB.First(&existing, existing.ID)
} else {
existing = model.BlacklistEntry{
TenantID: tenantID,
Kind: kind,
Value: value,
Reason: reason,
ExpiresAt: expiresAt,
OperatorID: operatorID,
SessionID: sessionID,
CustomerID: customerID,
}
if err := model.DB.Create(&existing).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "加入黑名单失败"})
return
}
}
// 会话事件 + 可选结束会话
if session != nil {
kindLabel := "IP"
if kind == "device" {
kindLabel = "设备"
}
detail := userDisplayName(operatorID) + " 拉黑" + kindLabel + " " + maskBlacklistValue(kind, value) +
"" + durationLabel(req.Duration) + "):" + reason
model.DB.Create(&model.SessionEvent{
SessionID: session.ID,
OperatorID: operatorID,
Action: "blacklist",
Detail: detail,
})
if req.EndSession && session.Status != "ended" && session.Status != "archived" {
endNow := time.Now()
_ = model.DB.Model(session).Updates(map[string]interface{}{
"status": "ended",
"end_reason": "other",
"ended_at": endNow,
}).Error
session.Status = "ended"
session.EndedAt = &endNow
model.DB.Create(&model.SessionEvent{
SessionID: session.ID,
OperatorID: operatorID,
Action: "end",
Detail: "结束会话: 拉黑访客",
})
broadcastSessionUpdate(session)
if payload, err := ws.NewEvent("session_updated", session.ID, session); err == nil {
ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload)
}
} else {
broadcastSessionUpdate(session)
}
}
middleware.JSON(c, existing)
}
func maskBlacklistValue(kind, value string) string {
if kind == "ip" {
parts := strings.Split(value, ".")
if len(parts) == 4 {
return parts[0] + "." + parts[1] + ".***." + parts[3]
}
return value
}
if len(value) <= 8 {
return value
}
return value[:4] + "…" + value[len(value)-4:]
}
// List 黑名单列表(有效 + 可选含已过期)。
func (h *BlacklistHandler) List(c *gin.Context) {
tenantID := middleware.GetTenantID(c)
q := model.DB.Where("tenant_id = ?", tenantID)
if c.Query("active") != "0" {
q = q.Where("expires_at IS NULL OR expires_at > ?", time.Now())
}
if kind := strings.TrimSpace(c.Query("kind")); kind == "ip" || kind == "device" {
q = q.Where("kind = ?", kind)
}
var list []model.BlacklistEntry
if err := q.Order("id desc").Limit(200).Find(&list).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询失败"})
return
}
middleware.JSON(c, list)
}
// Delete 解除黑名单。
func (h *BlacklistHandler) Delete(c *gin.Context) {
tenantID := middleware.GetTenantID(c)
id := c.Param("id")
var entry model.BlacklistEntry
if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&entry).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "记录不存在"})
return
}
// 通过将到期时间设为过去来“释放”,保留审计;若 force=1 则物理删除
if c.Query("force") == "1" {
if err := model.DB.Delete(&entry).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "删除失败"})
return
}
} else {
past := time.Now().Add(-time.Second)
if err := model.DB.Model(&entry).Update("expires_at", past).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "解除失败"})
return
}
entry.ExpiresAt = &past
}
middleware.JSON(c, gin.H{"message": "已解除", "id": entry.ID})
}
+7
View File
@@ -20,6 +20,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
channel := NewChannelHandler() channel := NewChannelHandler()
settings := NewSettingsHandler() settings := NewSettingsHandler()
staff := NewStaffHandler() staff := NewStaffHandler()
blacklist := NewBlacklistHandler()
wsHandler := NewWsHandler() wsHandler := NewWsHandler()
widget := NewWidgetHandler() widget := NewWidgetHandler()
upload := NewUploadHandler(store, storageCfg) upload := NewUploadHandler(store, storageCfg)
@@ -87,6 +88,12 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
customers.PUT("/:id", customer.Update) customers.PUT("/:id", customer.Update)
customers.DELETE("/:id", customer.Delete) customers.DELETE("/:id", customer.Delete)
// 黑名单(拉黑 IP / 设备)
bl := authRequired.Group("/blacklist")
bl.GET("", blacklist.List)
bl.POST("", blacklist.Create)
bl.DELETE("/:id", blacklist.Delete)
// 客户标签库(管理员维护,全员可读可选) // 客户标签库(管理员维护,全员可读可选)
ctags := authRequired.Group("/customer-tags") ctags := authRequired.Group("/customer-tags")
ctags.GET("", customerTag.List) ctags.GET("", customerTag.List)
+17
View File
@@ -22,6 +22,8 @@ func NewWidgetHandler() *WidgetHandler { return &WidgetHandler{} }
type WidgetInitReq struct { type WidgetInitReq struct {
ChannelKey string `json:"channel_key" form:"channel_key"` ChannelKey string `json:"channel_key" form:"channel_key"`
VisitorName string `json:"visitor_name"` VisitorName string `json:"visitor_name"`
// DeviceID 访客端持久设备指纹(localStorage),用于设备级拉黑
DeviceID string `json:"device_id" form:"device_id"`
// 宿主页信息(由 widget.js / 前端上报) // 宿主页信息(由 widget.js / 前端上报)
PageURL string `json:"page_url" form:"page_url"` PageURL string `json:"page_url" form:"page_url"`
PageTitle string `json:"page_title" form:"page_title"` PageTitle string `json:"page_title" form:"page_title"`
@@ -120,6 +122,20 @@ func (h *WidgetHandler) Init(c *gin.Context) {
} }
visitorIP, visitorRegion, userAgent, _ := captureVisitorMeta(c) visitorIP, visitorRegion, userAgent, _ := captureVisitorMeta(c)
deviceKey := normalizeDeviceKey(req.DeviceID, userAgent)
// 黑名单拦截(IP / 设备)
if blocked, blKind, blReason := checkVisitorBlacklist(channel.TenantID, visitorIP, deviceKey); blocked {
kindLabel := "IP"
if blKind == "device" {
kindLabel = "设备"
}
msg := "您暂时无法使用在线客服"
if strings.TrimSpace(blReason) != "" {
msg = msg + "" + kindLabel + "限制)"
}
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": msg})
return
}
pageURL := sanitizePageURL(req.PageURL) pageURL := sanitizePageURL(req.PageURL)
if pageURL == "" { if pageURL == "" {
// query 兜底 // query 兜底
@@ -144,6 +160,7 @@ func (h *WidgetHandler) Init(c *gin.Context) {
VisitorIP: visitorIP, VisitorIP: visitorIP,
VisitorRegion: visitorRegion, VisitorRegion: visitorRegion,
UserAgent: userAgent, UserAgent: userAgent,
DeviceKey: deviceKey,
LandingURL: pageURL, LandingURL: pageURL,
LandingTitle: pageTitle, LandingTitle: pageTitle,
Referrer: referrer, Referrer: referrer,
+1
View File
@@ -35,6 +35,7 @@ func Migrate(db *gorm.DB) error {
&Customer{}, &Customer{},
&CustomerTag{}, &CustomerTag{},
&CustomerContact{}, &CustomerContact{},
&BlacklistEntry{},
&Session{}, &Session{},
&VisitorPageView{}, &VisitorPageView{},
&Message{}, &Message{},
+18
View File
@@ -76,6 +76,22 @@ type CustomerTag struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// BlacklistEntry 租户级访客黑名单(按 IP 或设备指纹拦截)。
// ExpiresAt 为空表示长期有效;允许多条历史记录,拦截时取未过期的最新一条。
type BlacklistEntry struct {
ID uint `gorm:"primaryKey" json:"id"`
TenantID uint `gorm:"index:idx_bl_lookup;not null" json:"tenant_id"`
Kind string `gorm:"size:20;index:idx_bl_lookup;not null" json:"kind"` // ip | device
Value string `gorm:"size:200;index:idx_bl_lookup;not null" json:"value"`
Reason string `gorm:"size:500" json:"reason"`
ExpiresAt *time.Time `json:"expires_at"` // nil = 长期
OperatorID uint `gorm:"index" json:"operator_id"`
SessionID *uint `json:"session_id"`
CustomerID *uint `json:"customer_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Session struct { type Session 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"`
@@ -86,6 +102,8 @@ type Session struct {
VisitorIP string `gorm:"size:64" json:"visitor_ip"` VisitorIP string `gorm:"size:64" json:"visitor_ip"`
VisitorRegion string `gorm:"size:100" json:"visitor_region"` VisitorRegion string `gorm:"size:100" json:"visitor_region"`
UserAgent string `gorm:"size:500" json:"user_agent"` UserAgent string `gorm:"size:500" json:"user_agent"`
// DeviceKey 访客端持久设备指纹(localStorage),用于设备级拉黑
DeviceKey string `gorm:"size:64;index" json:"device_key"`
// 落地页 / 当前页(访客浏览轨迹) // 落地页 / 当前页(访客浏览轨迹)
LandingURL string `gorm:"size:1000" json:"landing_url"` LandingURL string `gorm:"size:1000" json:"landing_url"`
LandingTitle string `gorm:"size:200" json:"landing_title"` LandingTitle string `gorm:"size:200" json:"landing_title"`
+2 -1
View File
@@ -3,7 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { import {
AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined, AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined,
FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined, FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined,
ThunderboltOutlined, DownOutlined, ThunderboltOutlined, DownOutlined, StopOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { Dropdown, message as antMsg } from 'antd' import { Dropdown, message as antMsg } from 'antd'
import { useAuth } from '@/stores/auth' import { useAuth } from '@/stores/auth'
@@ -15,6 +15,7 @@ const menuItems = [
{ key: '/agent/chat-history', icon: <HistoryOutlined />, label: '对话记录' }, { key: '/agent/chat-history', icon: <HistoryOutlined />, label: '对话记录' },
{ key: '/agent/knowledge', icon: <FileTextOutlined />, label: '知识库' }, { key: '/agent/knowledge', icon: <FileTextOutlined />, label: '知识库' },
{ key: '/agent/quick-replies', icon: <ThunderboltOutlined />, label: '快捷回复' }, { key: '/agent/quick-replies', icon: <ThunderboltOutlined />, label: '快捷回复' },
{ key: '/agent/blacklist', icon: <StopOutlined />, label: '黑名单' },
{ key: '/agent/statistics', icon: <BarChartOutlined />, label: '数据统计' }, { key: '/agent/statistics', icon: <BarChartOutlined />, label: '数据统计' },
{ key: '/agent/settings', icon: <SettingOutlined />, label: '系统设置' }, { key: '/agent/settings', icon: <SettingOutlined />, label: '系统设置' },
] ]
+235
View File
@@ -0,0 +1,235 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Input, Popconfirm, Select, Spin, Table, Tag, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { ReloadOutlined, StopOutlined } from '@ant-design/icons'
import { getBlacklist, releaseBlacklist, type BlacklistEntry } from '@/services/api'
function isActive(entry: BlacklistEntry) {
if (!entry.expires_at) return true
return new Date(entry.expires_at).getTime() > Date.now()
}
function formatExpire(entry: BlacklistEntry) {
if (!entry.expires_at) return '长期'
const t = new Date(entry.expires_at)
if (Number.isNaN(t.getTime())) return '—'
const active = t.getTime() > Date.now()
const text = t.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})
return active ? text : `${text}(已过期)`
}
function maskValue(kind: string, value: string) {
if (kind === 'ip') {
const parts = value.split('.')
if (parts.length === 4) return `${parts[0]}.${parts[1]}.***.${parts[3]}`
return value
}
if (value.length <= 10) return value
return `${value.slice(0, 6)}${value.slice(-4)}`
}
const Blacklist = () => {
const [list, setList] = useState<BlacklistEntry[]>([])
const [loading, setLoading] = useState(false)
const [kind, setKind] = useState<string | undefined>()
const [showExpired, setShowExpired] = useState(false)
const [search, setSearch] = useState('')
const [releasingId, setReleasingId] = useState<number | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const res = await getBlacklist({
kind: kind || undefined,
active: showExpired ? false : true,
})
setList(Array.isArray(res.data) ? res.data : [])
} catch (e) {
setList([])
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [kind, showExpired])
useEffect(() => {
void load()
}, [load])
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return list
return list.filter(item =>
item.value.toLowerCase().includes(q)
|| (item.reason || '').toLowerCase().includes(q),
)
}, [list, search])
const handleRelease = async (id: number) => {
setReleasingId(id)
try {
await releaseBlacklist(id)
message.success('已解除拉黑')
await load()
} catch (e) {
message.error(e instanceof Error ? e.message : '解除失败')
} finally {
setReleasingId(null)
}
}
const columns: ColumnsType<BlacklistEntry> = [
{
title: '类型',
dataIndex: 'kind',
width: 90,
render: (k: string) => (
<Tag color={k === 'ip' ? 'blue' : 'purple'}>
{k === 'ip' ? 'IP' : k === 'device' ? '设备' : k}
</Tag>
),
},
{
title: '目标',
dataIndex: 'value',
ellipsis: true,
render: (v: string, row) => (
<span className="font-mono text-[13px]" title={v}>
{maskValue(row.kind, v)}
</span>
),
},
{
title: '原因',
dataIndex: 'reason',
ellipsis: true,
render: (t: string) => t || '—',
},
{
title: '状态',
width: 90,
render: (_, row) => (
isActive(row)
? <Tag color="error"></Tag>
: <Tag></Tag>
),
},
{
title: '释放时间',
width: 180,
render: (_, row) => (
<span className="text-[13px] text-neutral-600">{formatExpire(row)}</span>
),
},
{
title: '拉黑时间',
dataIndex: 'created_at',
width: 170,
render: (t?: string) => t
? new Date(t).toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})
: '—',
},
{
title: '操作',
width: 100,
fixed: 'right',
render: (_, row) => (
isActive(row) ? (
<Popconfirm
title="确认解除该黑名单?"
description="解除后访客可重新进入在线客服"
onConfirm={() => handleRelease(row.id)}
okText="解除"
cancelText="取消"
>
<Button type="link" size="small" danger loading={releasingId === row.id}>
</Button>
</Popconfirm>
) : (
<span className="text-xs text-neutral-400"></span>
)
),
},
]
return (
<div className="h-full flex flex-col bg-[#f8fafc]">
<div className="shrink-0 bg-white border-b border-neutral-200 px-6 py-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<StopOutlined className="text-[#dc2626]" />
<h1 className="m-0 text-lg font-semibold text-neutral-900"></h1>
</div>
<p className="m-0 mt-1 text-sm text-neutral-500">
IP / 访
</p>
</div>
<Button icon={<ReloadOutlined />} onClick={() => void load()} loading={loading}>
</Button>
</div>
<div className="flex flex-wrap items-center gap-2 mt-4">
<Select
allowClear
placeholder="全部类型"
className="w-32"
value={kind}
onChange={v => setKind(v)}
options={[
{ value: 'ip', label: 'IP' },
{ value: 'device', label: '设备' },
]}
/>
<Select
className="w-36"
value={showExpired ? 'all' : 'active'}
onChange={v => setShowExpired(v === 'all')}
options={[
{ value: 'active', label: '仅生效中' },
{ value: 'all', label: '含已过期' },
]}
/>
<Input.Search
allowClear
placeholder="搜索 IP / 设备 / 原因"
className="w-64"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
</div>
<div className="flex-1 min-h-0 p-6 overflow-auto">
<div className="bg-white rounded-xl border border-neutral-200 overflow-hidden">
{loading && list.length === 0 ? (
<div className="py-20 flex justify-center"><Spin /></div>
) : (
<Table
rowKey="id"
size="middle"
columns={columns}
dataSource={filtered}
pagination={{
pageSize: 20,
showTotal: t => `${t}`,
showSizeChanger: false,
}}
locale={{ emptyText: '暂无黑名单记录' }}
scroll={{ x: 900 }}
/>
)}
</div>
</div>
</div>
)
}
export default Blacklist
+154 -4
View File
@@ -1,19 +1,20 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { Button, Dropdown, Input, Modal, Select, Spin, message as antMsg, Popover } from 'antd' import { Button, Checkbox, Dropdown, Input, Modal, Radio, Select, Spin, message as antMsg, Popover } from 'antd'
import { import {
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined, CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined, SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined,
ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined, ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined,
StopOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker' import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
import { ChatImage } from '@/components/common/ImagePreview' import { ChatImage } from '@/components/common/ImagePreview'
import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody' import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody'
import { useAuth } from '@/stores/auth' import { useAuth } from '@/stores/auth'
import { import {
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries, addSessionNote, claimSession, createBlacklist, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries,
getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage, getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
suggestQuickReplies, transferSession, updateCustomer, updateSessionPriority, uploadImage, useQuickReply, suggestQuickReplies, transferSession, updateCustomer, updateSessionPriority, uploadImage, useQuickReply,
type AvailableAgent, type Customer, type CustomerContact, type KnowledgeEntry, type Message, type QuickReply, type AvailableAgent, type BlacklistDuration, type Customer, type CustomerContact, type KnowledgeEntry, type Message, type QuickReply,
type Session, type SessionEvent, type VisitorPageView, type Session, type SessionEvent, type VisitorPageView,
} from '@/services/api' } from '@/services/api'
@@ -257,6 +258,12 @@ const Dashboard = () => {
const [transferOpen, setTransferOpen] = useState(false) const [transferOpen, setTransferOpen] = useState(false)
const [availableAgents, setAvailableAgents] = useState<AvailableAgent[]>([]) const [availableAgents, setAvailableAgents] = useState<AvailableAgent[]>([])
const [targetAgentID, setTargetAgentID] = useState<number>() const [targetAgentID, setTargetAgentID] = useState<number>()
const [blacklistOpen, setBlacklistOpen] = useState(false)
const [blacklistKind, setBlacklistKind] = useState<'ip' | 'device'>('ip')
const [blacklistDuration, setBlacklistDuration] = useState<BlacklistDuration>('7d')
const [blacklistReason, setBlacklistReason] = useState('')
const [blacklistEndSession, setBlacklistEndSession] = useState(true)
const [blacklistSaving, setBlacklistSaving] = useState(false)
const [endingOpen, setEndingOpen] = useState(false) const [endingOpen, setEndingOpen] = useState(false)
const [endReason, setEndReason] = useState('resolved') const [endReason, setEndReason] = useState('resolved')
const [knowledgeOpen, setKnowledgeOpen] = useState(false) const [knowledgeOpen, setKnowledgeOpen] = useState(false)
@@ -857,7 +864,8 @@ const Dashboard = () => {
|| event.action === 'assign' || event.action === 'assign'
|| event.action === 'auto_assign' || event.action === 'auto_assign'
|| event.action === 'end' || event.action === 'end'
|| event.action === 'offline_leave', || event.action === 'offline_leave'
|| event.action === 'blacklist',
) )
const notes = detail?.events const notes = detail?.events
.filter(event => .filter(event =>
@@ -866,6 +874,7 @@ const Dashboard = () => {
|| event.action === 'auto_assign' || event.action === 'auto_assign'
|| event.action === 'assign' || event.action === 'assign'
|| event.action === 'transfer' || event.action === 'transfer'
|| event.action === 'blacklist'
|| event.action === 'end', || event.action === 'end',
) )
.slice() .slice()
@@ -890,6 +899,7 @@ const Dashboard = () => {
const eventLabel = (action: string) => { const eventLabel = (action: string) => {
switch (action) { switch (action) {
case 'transfer': return '会话转接' case 'transfer': return '会话转接'
case 'blacklist': return '加入黑名单'
case 'assign': return '人工分配' case 'assign': return '人工分配'
case 'auto_assign': return '自动分配' case 'auto_assign': return '自动分配'
case 'end': return '结束会话' case 'end': return '结束会话'
@@ -1007,6 +1017,55 @@ const Dashboard = () => {
} }
} }
const openBlacklist = () => {
if (!selected) return
setBlacklistKind(selected.visitor_ip ? 'ip' : 'device')
setBlacklistDuration('7d')
setBlacklistReason('')
setBlacklistEndSession(selected.status === 'active' || selected.status === 'waiting')
setBlacklistOpen(true)
}
const handleBlacklist = async () => {
if (!selected) return
const reason = blacklistReason.trim()
if (!reason) {
antMsg.warning('请填写拉黑原因')
return
}
if (blacklistKind === 'ip' && !selected.visitor_ip) {
antMsg.warning('该会话无有效 IP,请改选「设备」')
return
}
if (blacklistKind === 'device' && !selected.device_key && !selected.user_agent) {
antMsg.warning('该会话无设备标识,请改选「IP」')
return
}
setBlacklistSaving(true)
try {
await createBlacklist({
session_id: selected.id,
kind: blacklistKind,
duration: blacklistDuration,
reason,
end_session: blacklistEndSession,
})
antMsg.success(blacklistKind === 'ip' ? '已拉黑该 IP' : '已拉黑该设备')
setBlacklistOpen(false)
if (blacklistEndSession) {
setSelectedId(null)
setDetail(null)
} else if (selectedId) {
await loadDetail(selectedId, false)
}
await loadAll()
} catch (error) {
antMsg.error(error instanceof Error ? error.message : '拉黑失败')
} finally {
setBlacklistSaving(false)
}
}
const handlePriority = async (priority: 'normal' | 'urgent') => { const handlePriority = async (priority: 'normal' | 'urgent') => {
if (!selected) return if (!selected) return
try { try {
@@ -1312,6 +1371,15 @@ const Dashboard = () => {
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-40" title="转接" disabled={!canOperate} onClick={openTransfer}> <button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-40" title="转接" disabled={!canOperate} onClick={openTransfer}>
<SwapOutlined /> <SwapOutlined />
</button> </button>
<button
type="button"
className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-40"
title="拉黑"
disabled={!selected}
onClick={openBlacklist}
>
<StopOutlined />
</button>
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-red-500 hover:bg-red-50 disabled:opacity-40" title="结束会话" disabled={!canOperate} onClick={() => setEndingOpen(true)}> <button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-red-500 hover:bg-red-50 disabled:opacity-40" title="结束会话" disabled={!canOperate} onClick={() => setEndingOpen(true)}>
<CheckCircleOutlined /> <CheckCircleOutlined />
</button> </button>
@@ -1965,6 +2033,88 @@ const Dashboard = () => {
<p className="text-sm text-neutral-500 mb-3"></p> <p className="text-sm text-neutral-500 mb-3"></p>
<Select className="w-full" value={endReason} onChange={setEndReason} options={endReasons} /> <Select className="w-full" value={endReason} onChange={setEndReason} options={endReasons} />
</Modal> </Modal>
<Modal
title="拉黑访客"
open={blacklistOpen}
onCancel={() => setBlacklistOpen(false)}
onOk={handleBlacklist}
okText="确认拉黑"
okButtonProps={{ danger: true, loading: blacklistSaving, disabled: !blacklistReason.trim() }}
cancelButtonProps={{ disabled: blacklistSaving }}
destroyOnClose
>
<div className="flex flex-col gap-3.5 pt-1">
<div>
<div className="text-sm text-neutral-600 mb-2"></div>
<Radio.Group
value={blacklistKind}
onChange={e => setBlacklistKind(e.target.value)}
optionType="button"
buttonStyle="solid"
options={[
{
value: 'ip',
label: selected?.visitor_ip ? `IP${selected.visitor_ip}` : 'IP(无)',
disabled: !selected?.visitor_ip,
},
{
value: 'device',
label: selected?.device_key || selected?.user_agent
? '设备'
: '设备(无)',
disabled: !selected?.device_key && !selected?.user_agent,
},
]}
/>
{blacklistKind === 'device' && (
<div className="mt-1.5 text-xs text-neutral-400 truncate" title={selected?.device_key || selected?.user_agent}>
{selected?.device_key
? `设备标识:${selected.device_key.slice(0, 12)}`
: selected?.user_agent
? `将按浏览器指纹:${selected.user_agent.slice(0, 48)}`
: ''}
</div>
)}
</div>
<div>
<div className="text-sm text-neutral-600 mb-2"></div>
<Select
className="w-full"
value={blacklistDuration}
onChange={v => setBlacklistDuration(v)}
options={[
{ value: '1h', label: '1 小时后自动解除' },
{ value: '6h', label: '6 小时后自动解除' },
{ value: '1d', label: '1 天后自动解除' },
{ value: '7d', label: '7 天后自动解除' },
{ value: '30d', label: '30 天后自动解除' },
{ value: 'permanent', label: '长期(不自动解除)' },
]}
/>
</div>
<div>
<div className="text-sm text-neutral-600 mb-2"> <span className="text-red-500">*</span></div>
<Input.TextArea
value={blacklistReason}
onChange={e => setBlacklistReason(e.target.value)}
placeholder="例如:恶意骚扰、发送垃圾信息…"
maxLength={200}
showCount
rows={3}
/>
</div>
<Checkbox
checked={blacklistEndSession}
onChange={e => setBlacklistEndSession(e.target.checked)}
disabled={selected?.status === 'ended' || selected?.status === 'archived'}
>
</Checkbox>
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-md px-2.5 py-2 leading-relaxed">
{blacklistKind === 'ip' ? ' IP ' : '设备'}访线
</div>
</div>
</Modal>
<Modal <Modal
title="图片预览" title="图片预览"
open={Boolean(pendingImage)} open={Boolean(pendingImage)}
+2
View File
@@ -11,6 +11,7 @@ const ChatHistory = lazy(() => import('@/pages/agent/ChatHistory'))
const Customers = lazy(() => import('@/pages/agent/Customers')) const Customers = lazy(() => import('@/pages/agent/Customers'))
const Knowledge = lazy(() => import('@/pages/agent/Knowledge')) const Knowledge = lazy(() => import('@/pages/agent/Knowledge'))
const QuickReplies = lazy(() => import('@/pages/agent/QuickReplies')) const QuickReplies = lazy(() => import('@/pages/agent/QuickReplies'))
const Blacklist = lazy(() => import('@/pages/agent/Blacklist'))
const Statistics = lazy(() => import('@/pages/agent/Statistics')) const Statistics = lazy(() => import('@/pages/agent/Statistics'))
const Settings = lazy(() => import('@/pages/agent/Settings')) const Settings = lazy(() => import('@/pages/agent/Settings'))
const AdminDashboard = lazy(() => import('@/pages/admin/Dashboard')) const AdminDashboard = lazy(() => import('@/pages/admin/Dashboard'))
@@ -68,6 +69,7 @@ export const router = createBrowserRouter([
{ path: 'customers', element: <Lazy><Customers /></Lazy> }, { path: 'customers', element: <Lazy><Customers /></Lazy> },
{ path: 'knowledge', element: <Lazy><Knowledge /></Lazy> }, { path: 'knowledge', element: <Lazy><Knowledge /></Lazy> },
{ path: 'quick-replies', element: <Lazy><QuickReplies /></Lazy> }, { path: 'quick-replies', element: <Lazy><QuickReplies /></Lazy> },
{ path: 'blacklist', element: <Lazy><Blacklist /></Lazy> },
{ {
element: <RequireSupervisor />, element: <RequireSupervisor />,
children: [{ path: 'statistics', element: <Lazy><Statistics /></Lazy> }], children: [{ path: 'statistics', element: <Lazy><Statistics /></Lazy> }],
+37
View File
@@ -20,6 +20,8 @@ export interface Session {
visitor_ip?: string visitor_ip?: string
visitor_region?: string visitor_region?: string
user_agent?: string user_agent?: string
/** 访客设备指纹(用于设备拉黑) */
device_key?: string
landing_url?: string landing_url?: string
landing_title?: string landing_title?: string
referrer?: string referrer?: string
@@ -349,6 +351,41 @@ export const createCustomer = (data: Partial<Customer>) => post<Customer>('/cust
export const updateCustomer = (id: number, data: Partial<Customer>) => put<Customer>(`/customers/${id}`, data) export const updateCustomer = (id: number, data: Partial<Customer>) => put<Customer>(`/customers/${id}`, data)
export const deleteCustomer = (id: number) => del(`/customers/${id}`) export const deleteCustomer = (id: number) => del(`/customers/${id}`)
// 黑名单
export interface BlacklistEntry {
id: number
tenant_id: number
kind: 'ip' | 'device' | string
value: string
reason: string
expires_at?: string | null
operator_id?: number
session_id?: number | null
customer_id?: number | null
created_at?: string
}
export type BlacklistDuration = '1h' | '6h' | '1d' | '7d' | '30d' | 'permanent'
export const createBlacklist = (data: {
session_id?: number
kind: 'ip' | 'device'
value?: string
duration: BlacklistDuration | string
reason: string
end_session?: boolean
}) => post<BlacklistEntry>('/blacklist', data)
export const getBlacklist = (params?: { kind?: string; active?: boolean }) => {
const search = new URLSearchParams()
if (params?.kind) search.set('kind', params.kind)
if (params?.active === false) search.set('active', '0')
const qs = search.toString()
return get<BlacklistEntry[]>(`/blacklist${qs ? `?${qs}` : ''}`)
}
export const releaseBlacklist = (id: number) => del(`/blacklist/${id}`)
// 客户标签库(管理员维护,全员可选) // 客户标签库(管理员维护,全员可选)
export interface CustomerTag { export interface CustomerTag {
id: number id: number
+17
View File
@@ -354,6 +354,22 @@ const VisitorChat = ({
} }
}, [sessionEnded]) }, [sessionEnded])
/** 访客端持久设备指纹(用于设备拉黑) */
const getOrCreateDeviceId = () => {
const key = 'kefu_device_id'
try {
let id = localStorage.getItem(key)
if (id && id.length >= 8) return id
id = (typeof crypto !== 'undefined' && crypto.randomUUID)
? crypto.randomUUID().replace(/-/g, '')
: `d${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`
localStorage.setItem(key, id)
return id
} catch {
return `d${Date.now().toString(36)}`
}
}
/** 调用 Init 创建新会话并写入本地状态(不复用已结束会话) */ /** 调用 Init 创建新会话并写入本地状态(不复用已结束会话) */
const bootstrapNewSession = useCallback(async () => { const bootstrapNewSession = useCallback(async () => {
const page = hostPageRef.current const page = hostPageRef.current
@@ -363,6 +379,7 @@ const VisitorChat = ({
body: JSON.stringify({ body: JSON.stringify({
channel_key: channelKey, channel_key: channelKey,
visitor_name: '访客', visitor_name: '访客',
device_id: getOrCreateDeviceId(),
page_url: page.url || '', page_url: page.url || '',
page_title: page.title || '', page_title: page.title || '',
referrer: page.referrer || '', referrer: page.referrer || '',