Files
kefu_cloud/server/internal/handler/blacklist.go
T

354 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) {
if !middleware.HasPermission(c, "blacklist.create") {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可管理黑名单"})
return
}
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
}
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) {
if !middleware.HasPermission(c, "blacklist.view") {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可查看黑名单"})
return
}
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) {
if !middleware.HasPermission(c, "blacklist.delete") {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可解除黑名单"})
return
}
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})
}