Files
hyapi-server/internal/domains/security/services/blacklist_service.go
2026-07-15 21:44:13 +08:00

229 lines
5.7 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 services
import (
"context"
"fmt"
"net"
"strings"
"sync"
"time"
"hyapi-server/internal/domains/security/entities"
"go.uber.org/zap"
"gorm.io/gorm"
)
// BlacklistService 黑名单管理与拦截记录
type BlacklistService struct {
db *gorm.DB
logger *zap.Logger
mu sync.RWMutex
ipCache map[string]entities.IPBlacklistEntry
cacheAt time.Time
cacheTTL time.Duration
}
func NewBlacklistService(db *gorm.DB, logger *zap.Logger) *BlacklistService {
return &BlacklistService{
db: db,
logger: logger,
ipCache: map[string]entities.IPBlacklistEntry{},
cacheTTL: 30 * time.Second,
}
}
func (s *BlacklistService) InvalidateIPCache() {
s.mu.Lock()
defer s.mu.Unlock()
s.ipCache = map[string]entities.IPBlacklistEntry{}
s.cacheAt = time.Time{}
}
func (s *BlacklistService) refreshIPCache(ctx context.Context) error {
s.mu.RLock()
fresh := time.Since(s.cacheAt) < s.cacheTTL && s.cacheAt.Unix() > 0
s.mu.RUnlock()
if fresh {
return nil
}
var list []entities.IPBlacklistEntry
if err := s.db.WithContext(ctx).Where("is_active = ?", true).Find(&list).Error; err != nil {
return err
}
m := make(map[string]entities.IPBlacklistEntry, len(list))
for _, e := range list {
m[strings.TrimSpace(e.IP)] = e
}
s.mu.Lock()
s.ipCache = m
s.cacheAt = time.Now()
s.mu.Unlock()
return nil
}
// IsIPBlocked 检查 IP 是否在管理端黑名单(支持精确 IP
func (s *BlacklistService) IsIPBlocked(ctx context.Context, ip string) (bool, *entities.IPBlacklistEntry) {
ip = strings.TrimSpace(ip)
if ip == "" {
return false, nil
}
_ = s.refreshIPCache(ctx)
s.mu.RLock()
defer s.mu.RUnlock()
if e, ok := s.ipCache[ip]; ok {
cp := e
return true, &cp
}
// 简易通配:缓存里带 * 的模式
for pattern, e := range s.ipCache {
if matchIPPattern(ip, pattern) {
cp := e
return true, &cp
}
}
return false, nil
}
func matchIPPattern(ip, pattern string) bool {
if !strings.Contains(pattern, "*") {
return ip == pattern
}
parts := strings.Split(pattern, ".")
clientParts := strings.Split(ip, ".")
if len(parts) != 4 || len(clientParts) != 4 {
return false
}
for i := range parts {
if parts[i] != "*" && parts[i] != clientParts[i] {
return false
}
}
return true
}
// RecordHit 异步写入拦截记录
func (s *BlacklistService) RecordHit(hit *entities.BlacklistHitRecord) {
if hit == nil {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := s.db.WithContext(ctx).Create(hit).Error; err != nil {
s.logger.Warn("写入黑名单拦截记录失败", zap.Error(err))
}
}()
}
func (s *BlacklistService) ListIP(ctx context.Context, page, pageSize int, keyword string) ([]entities.IPBlacklistEntry, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
q := s.db.WithContext(ctx).Model(&entities.IPBlacklistEntry{}).Where("is_active = ?", true)
if keyword != "" {
q = q.Where("ip LIKE ? OR reason LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []entities.IPBlacklistEntry
err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error
return list, total, err
}
func (s *BlacklistService) AddIP(ctx context.Context, ip, reason, source, createdBy string) (*entities.IPBlacklistEntry, error) {
ip = strings.TrimSpace(ip)
if ip == "" {
return nil, fmt.Errorf("IP不能为空")
}
if strings.Contains(ip, "*") {
// 允许简单通配,跳过 net.ParseIP
} else if net.ParseIP(ip) == nil {
return nil, fmt.Errorf("IP格式无效")
}
if reason == "" {
return nil, fmt.Errorf("原因不能为空")
}
if source == "" {
source = "self"
}
var existing entities.IPBlacklistEntry
err := s.db.WithContext(ctx).Where("ip = ?", ip).First(&existing).Error
if err == nil {
if existing.IsActive {
return nil, fmt.Errorf("该IP已在黑名单中")
}
existing.IsActive = true
existing.Reason = reason
existing.Source = source
existing.CreatedBy = createdBy
if err := s.db.WithContext(ctx).Save(&existing).Error; err != nil {
return nil, err
}
s.InvalidateIPCache()
return &existing, nil
}
if err != gorm.ErrRecordNotFound {
return nil, err
}
entry := &entities.IPBlacklistEntry{
IP: ip,
Reason: reason,
Source: source,
CreatedBy: createdBy,
IsActive: true,
}
if err := s.db.WithContext(ctx).Create(entry).Error; err != nil {
return nil, err
}
s.InvalidateIPCache()
return entry, nil
}
func (s *BlacklistService) RemoveIP(ctx context.Context, id uint64) error {
res := s.db.WithContext(ctx).Model(&entities.IPBlacklistEntry{}).
Where("id = ? AND is_active = ?", id, true).
Updates(map[string]interface{}{"is_active": false})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return fmt.Errorf("记录不存在或已移除")
}
s.InvalidateIPCache()
return nil
}
func (s *BlacklistService) ListHits(ctx context.Context, page, pageSize int, hitType, keyword string) ([]entities.BlacklistHitRecord, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
q := s.db.WithContext(ctx).Model(&entities.BlacklistHitRecord{})
if hitType != "" {
q = q.Where("hit_type = ?", hitType)
}
if keyword != "" {
like := "%" + keyword + "%"
q = q.Where("phone LIKE ? OR ip LIKE ? OR user_id LIKE ? OR path LIKE ? OR reason LIKE ?", like, like, like, like, like)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []entities.BlacklistHitRecord
err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error
return list, total, err
}