新增访客黑名单:支持拉黑 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()
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)
+17
View File
@@ -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,