Files
kefu_cloud/server/internal/handler/blacklist.go
T
yml2213 8b9f3d710f refactor(server): 会话/访客/设置业务逻辑下沉 internal/service 层,handler 瘦身
- handler 只保留请求绑定、权限检查与响应映射;业务校验顺序、状态码与文案逐字保留
- 新增 service.Error 语义错误,由 handler 统一映射 HTTP 响应
- session 875→361 行、widget 745→321 行、settings 540→73 行
- 自动分配/租户设置(工作时间、欢迎语)/消息校验/黑名单检查/联系方式提取随迁 service 包
- 相关单元测试随迁 service 包,go test ./... 全部通过
2026-09-05 14:39:34 +08:00

307 lines
9.1 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 (
"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/service"
"kefu-cloud/server/internal/ws"
)
type BlacklistHandler struct{}
func NewBlacklistHandler() *BlacklistHandler { return &BlacklistHandler{} }
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, err := service.LoadTenantSession(fmt.Sprintf("%d", *req.SessionID), middleware.GetTenantID(c))
if err != nil {
respondServiceError(c, err)
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 = service.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 := service.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: "结束会话: 拉黑访客",
})
service.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 {
service.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})
}