新增访客黑名单:支持拉黑 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,
|
||||
|
||||
Reference in New Issue
Block a user