Files
kefu_cloud/server/internal/handler/blacklist.go
T
yml2213 1f92c35963 新增访客黑名单:支持拉黑 IP/设备并管理列表
工作台可按 IP 或设备拉黑并填写释放时间与原因;侧栏黑名单页查看与解除;进线自动拦截。
2026-07-19 00:43:55 +08:00

349 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})
}