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}) }