// Copyright 2026 tamakyi. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package db import ( "context" "time" "gorm.io/gorm" ) // BanRecord 是一次自动封禁的落库记录。 // // 为什么需要它:自动封禁的生效状态是**进程内存**里的(见 internal/security/autoban), // 重启即清零,外部完全看不见。线上出现过「邮件说封了、实际还能访问」的排查困境 —— // 当时没有任何东西能说明封的是哪个键、因为什么、到什么时候解除,只能靠翻日志猜。 // // 这张表是「封禁事实」的账本,不参与判定:IsBanned 仍然只读内存, // 表只用于事后解释与取证。写入失败绝不能影响封禁动作本身。 // // 增长:每个键在封禁期内不会重复建封(autoban 命中已封禁直接拒绝), // 所以行数量级 = 封禁次数,不需要自动清理。要清可手动 DELETE。 type BanRecord struct { ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `gorm:"index" json:"created_at"` // BanKey 与 autoban.IsBanned 的判断口径完全一致(shared.LimitKey, // IPv6 折成 /64),否则账本与判定会各说一套。 BanKey string `gorm:"size:64;index" json:"ban_key"` // Reason 是给人看的原因文案(敏感路径探测 / 蜜罐提交凭据)。 Reason string `gorm:"size:64" json:"reason"` // DurationMinutes 与 ExpiresAt 一起回答「封多久、什么时候自动解除」。 DurationMinutes int `gorm:"not null;default:0" json:"duration_minutes"` ExpiresAt time.Time `gorm:"index" json:"expires_at"` } // TableName 固定表名,避免 GORM 复数化差异。 func (BanRecord) TableName() string { return "ban_records" } // BanRecords 自动封禁记录的全局 store,由 db.Init 装配。 var BanRecords BanRecordStore // BanRecordStore 封禁记录的读写。 type BanRecordStore interface { // Create 追加一条封禁记录。 Create(ctx context.Context, record *BanRecord) error // ListSince 返回 since 之后的记录,按时间倒序,最多 limit 条。 ListSince(ctx context.Context, since time.Time, limit int) ([]*BanRecord, error) } type banRecords struct { db *gorm.DB } // NewBanRecordsStore 创建封禁记录 store。 func NewBanRecordsStore(db *gorm.DB) BanRecordStore { return &banRecords{db: db} } func (s *banRecords) Create(ctx context.Context, record *BanRecord) error { return s.db.WithContext(ctx).Create(record).Error } func (s *banRecords) ListSince(ctx context.Context, since time.Time, limit int) ([]*BanRecord, error) { var records []*BanRecord err := s.db.WithContext(ctx). Where("created_at >= ?", since). Order("created_at DESC"). Limit(limit). Find(&records).Error return records, err }