354 lines
10 KiB
Go
354 lines
10 KiB
Go
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})
|
||
}
|