229 lines
5.7 KiB
Go
229 lines
5.7 KiB
Go
|
|
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
|
|||
|
|
}
|