新增访客黑名单:支持拉黑 IP/设备并管理列表
工作台可按 IP 或设备拉黑并填写释放时间与原因;侧栏黑名单页查看与解除;进线自动拦截。
This commit is contained in:
@@ -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})
|
||||
}
|
||||
@@ -20,6 +20,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
channel := NewChannelHandler()
|
||||
settings := NewSettingsHandler()
|
||||
staff := NewStaffHandler()
|
||||
blacklist := NewBlacklistHandler()
|
||||
wsHandler := NewWsHandler()
|
||||
widget := NewWidgetHandler()
|
||||
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.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.GET("", customerTag.List)
|
||||
|
||||
@@ -22,6 +22,8 @@ func NewWidgetHandler() *WidgetHandler { return &WidgetHandler{} }
|
||||
type WidgetInitReq struct {
|
||||
ChannelKey string `json:"channel_key" form:"channel_key"`
|
||||
VisitorName string `json:"visitor_name"`
|
||||
// DeviceID 访客端持久设备指纹(localStorage),用于设备级拉黑
|
||||
DeviceID string `json:"device_id" form:"device_id"`
|
||||
// 宿主页信息(由 widget.js / 前端上报)
|
||||
PageURL string `json:"page_url" form:"page_url"`
|
||||
PageTitle string `json:"page_title" form:"page_title"`
|
||||
@@ -120,6 +122,20 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
||||
}
|
||||
|
||||
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)
|
||||
if pageURL == "" {
|
||||
// query 兜底
|
||||
@@ -144,6 +160,7 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
||||
VisitorIP: visitorIP,
|
||||
VisitorRegion: visitorRegion,
|
||||
UserAgent: userAgent,
|
||||
DeviceKey: deviceKey,
|
||||
LandingURL: pageURL,
|
||||
LandingTitle: pageTitle,
|
||||
Referrer: referrer,
|
||||
|
||||
@@ -35,6 +35,7 @@ func Migrate(db *gorm.DB) error {
|
||||
&Customer{},
|
||||
&CustomerTag{},
|
||||
&CustomerContact{},
|
||||
&BlacklistEntry{},
|
||||
&Session{},
|
||||
&VisitorPageView{},
|
||||
&Message{},
|
||||
|
||||
@@ -76,6 +76,22 @@ type CustomerTag struct {
|
||||
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 {
|
||||
ID uint `gorm:"primaryKey" json:"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"`
|
||||
VisitorRegion string `gorm:"size:100" json:"visitor_region"`
|
||||
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"`
|
||||
LandingTitle string `gorm:"size:200" json:"landing_title"`
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined,
|
||||
FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined,
|
||||
ThunderboltOutlined, DownOutlined,
|
||||
ThunderboltOutlined, DownOutlined, StopOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Dropdown, message as antMsg } from 'antd'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
@@ -15,6 +15,7 @@ const menuItems = [
|
||||
{ key: '/agent/chat-history', icon: <HistoryOutlined />, label: '对话记录' },
|
||||
{ key: '/agent/knowledge', icon: <FileTextOutlined />, label: '知识库' },
|
||||
{ key: '/agent/quick-replies', icon: <ThunderboltOutlined />, label: '快捷回复' },
|
||||
{ key: '/agent/blacklist', icon: <StopOutlined />, label: '黑名单' },
|
||||
{ key: '/agent/statistics', icon: <BarChartOutlined />, label: '数据统计' },
|
||||
{ key: '/agent/settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -1,19 +1,20 @@
|
||||
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 {
|
||||
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
|
||||
SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined,
|
||||
ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined,
|
||||
StopOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
|
||||
import { ChatImage } from '@/components/common/ImagePreview'
|
||||
import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
import {
|
||||
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries,
|
||||
addSessionNote, claimSession, createBlacklist, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries,
|
||||
getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
|
||||
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,
|
||||
} from '@/services/api'
|
||||
|
||||
@@ -257,6 +258,12 @@ const Dashboard = () => {
|
||||
const [transferOpen, setTransferOpen] = useState(false)
|
||||
const [availableAgents, setAvailableAgents] = useState<AvailableAgent[]>([])
|
||||
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 [endReason, setEndReason] = useState('resolved')
|
||||
const [knowledgeOpen, setKnowledgeOpen] = useState(false)
|
||||
@@ -857,7 +864,8 @@ const Dashboard = () => {
|
||||
|| event.action === 'assign'
|
||||
|| event.action === 'auto_assign'
|
||||
|| event.action === 'end'
|
||||
|| event.action === 'offline_leave',
|
||||
|| event.action === 'offline_leave'
|
||||
|| event.action === 'blacklist',
|
||||
)
|
||||
const notes = detail?.events
|
||||
.filter(event =>
|
||||
@@ -866,6 +874,7 @@ const Dashboard = () => {
|
||||
|| event.action === 'auto_assign'
|
||||
|| event.action === 'assign'
|
||||
|| event.action === 'transfer'
|
||||
|| event.action === 'blacklist'
|
||||
|| event.action === 'end',
|
||||
)
|
||||
.slice()
|
||||
@@ -890,6 +899,7 @@ const Dashboard = () => {
|
||||
const eventLabel = (action: string) => {
|
||||
switch (action) {
|
||||
case 'transfer': return '会话转接'
|
||||
case 'blacklist': return '加入黑名单'
|
||||
case 'assign': return '人工分配'
|
||||
case 'auto_assign': 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') => {
|
||||
if (!selected) return
|
||||
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}>
|
||||
<SwapOutlined />
|
||||
</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)}>
|
||||
<CheckCircleOutlined />
|
||||
</button>
|
||||
@@ -1965,6 +2033,88 @@ const Dashboard = () => {
|
||||
<p className="text-sm text-neutral-500 mb-3">请选择本次会话的结束原因。</p>
|
||||
<Select className="w-full" value={endReason} onChange={setEndReason} options={endReasons} />
|
||||
</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
|
||||
title="图片预览"
|
||||
open={Boolean(pendingImage)}
|
||||
|
||||
@@ -11,6 +11,7 @@ const ChatHistory = lazy(() => import('@/pages/agent/ChatHistory'))
|
||||
const Customers = lazy(() => import('@/pages/agent/Customers'))
|
||||
const Knowledge = lazy(() => import('@/pages/agent/Knowledge'))
|
||||
const QuickReplies = lazy(() => import('@/pages/agent/QuickReplies'))
|
||||
const Blacklist = lazy(() => import('@/pages/agent/Blacklist'))
|
||||
const Statistics = lazy(() => import('@/pages/agent/Statistics'))
|
||||
const Settings = lazy(() => import('@/pages/agent/Settings'))
|
||||
const AdminDashboard = lazy(() => import('@/pages/admin/Dashboard'))
|
||||
@@ -68,6 +69,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'customers', element: <Lazy><Customers /></Lazy> },
|
||||
{ path: 'knowledge', element: <Lazy><Knowledge /></Lazy> },
|
||||
{ path: 'quick-replies', element: <Lazy><QuickReplies /></Lazy> },
|
||||
{ path: 'blacklist', element: <Lazy><Blacklist /></Lazy> },
|
||||
{
|
||||
element: <RequireSupervisor />,
|
||||
children: [{ path: 'statistics', element: <Lazy><Statistics /></Lazy> }],
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface Session {
|
||||
visitor_ip?: string
|
||||
visitor_region?: string
|
||||
user_agent?: string
|
||||
/** 访客设备指纹(用于设备拉黑) */
|
||||
device_key?: string
|
||||
landing_url?: string
|
||||
landing_title?: 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 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 {
|
||||
id: number
|
||||
|
||||
@@ -354,6 +354,22 @@ const VisitorChat = ({
|
||||
}
|
||||
}, [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 创建新会话并写入本地状态(不复用已结束会话) */
|
||||
const bootstrapNewSession = useCallback(async () => {
|
||||
const page = hostPageRef.current
|
||||
@@ -363,6 +379,7 @@ const VisitorChat = ({
|
||||
body: JSON.stringify({
|
||||
channel_key: channelKey,
|
||||
visitor_name: '访客',
|
||||
device_id: getOrCreateDeviceId(),
|
||||
page_url: page.url || '',
|
||||
page_title: page.title || '',
|
||||
referrer: page.referrer || '',
|
||||
|
||||
Reference in New Issue
Block a user