- handler 只保留请求绑定、权限检查与响应映射;业务校验顺序、状态码与文案逐字保留 - 新增 service.Error 语义错误,由 handler 统一映射 HTTP 响应 - session 875→361 行、widget 745→321 行、settings 540→73 行 - 自动分配/租户设置(工作时间、欢迎语)/消息校验/黑名单检查/联系方式提取随迁 service 包 - 相关单元测试随迁 service 包,go test ./... 全部通过
307 lines
9.1 KiB
Go
307 lines
9.1 KiB
Go
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})
|
||
}
|