// 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" "gorm.io/gorm/clause" ) // SecurityReportLog 是「周期安全汇总已发送」的台账。 // // 为什么需要一张表而不是在内存里记一个时间戳: // 服务会被重启(发版、OOM、机器迁移)。内存标记一重启就丢,而报告窗口是一整个 // 自然周/月 —— 周一凌晨发过以后重启一次,下一次 tick 就会把同一周的周报再发一遍, // 连着发三次的情况在真实运维里很常见(发布窗口经常撞在周一)。 // // 用 period_key 唯一索引做幂等:无论触发多少次、重启多少回,同一周期只发一次。 type SecurityReportLog struct { ID uint `gorm:"primarykey"` // PeriodKey 周期标识,形如 "week:2026-W44" / "month:2026-10"。 // 唯一索引是幂等的唯一保证 —— 不要改成「按窗口时间范围查重」, // 那样跨月边界(一个月 4 周 vs 5 周)会出现漏发或重发的灰色地带。 PeriodKey string `gorm:"size:32;uniqueIndex"` // Kind 报告类型(week / month),便于按类型统计。 Kind string `gorm:"size:8"` // WindowStart / WindowEnd 报告覆盖的时间窗口 [start, end)。 WindowStart time.Time WindowEnd time.Time // 发送时的关键数字快照。台账要能独立回答「那周的结论是什么」, // 而不是事后还得回去翻蜜罐表 —— 蜜罐表有保留期,过期就被清掉了。 Hits int64 UniqueSources int64 Banned int64 Countries int Recipient string `gorm:"size:255"` // FilePath 报告 HTML 的落盘路径(留空表示只发了邮件、没留文件)。 FilePath string `gorm:"size:512"` // Sent 是否成功发出。失败的行保留下来,既是审计线索, // 也用来做「最多重试几次」的计数。 Sent bool // Attempts 尝试次数。发送失败会重试,但必须设上限: // 一个配错的 SMTP 会让每次 tick 都失败,无限重试只是把日志刷满。 Attempts int // Error 最近一次失败的原因(成功时清空)。 Error string `gorm:"size:512"` CreatedAt time.Time UpdatedAt time.Time } // TableName 固定表名。 func (SecurityReportLog) TableName() string { return "security_report_logs" } // SecurityReports 报告台账全局 store。 var SecurityReports SecurityReportStore // SecurityReportStore 报告台账的读写。 type SecurityReportStore interface { // Get 按 period_key 取一条,未找到返回 (nil, nil)。 Get(ctx context.Context, periodKey string) (*SecurityReportLog, error) // Save 按 period_key upsert。 Save(ctx context.Context, rec *SecurityReportLog) error // Recent 返回最近 n 条,按窗口结束时间倒序。 Recent(ctx context.Context, n int) ([]*SecurityReportLog, error) } type securityReports struct { db *gorm.DB } // NewSecurityReportStore 构造台账 store。 func NewSecurityReportStore(db *gorm.DB) SecurityReportStore { return &securityReports{db: db} } func (s *securityReports) Get(ctx context.Context, periodKey string) (*SecurityReportLog, error) { var rec SecurityReportLog err := s.db.WithContext(ctx).Where("period_key = ?", periodKey).First(&rec).Error if err != nil { if err == gorm.ErrRecordNotFound { return nil, nil } return nil, err } return &rec, nil } func (s *securityReports) Save(ctx context.Context, rec *SecurityReportLog) error { return s.db.WithContext(ctx). Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "period_key"}}, DoUpdates: clause.AssignmentColumns([]string{ "kind", "window_start", "window_end", "hits", "unique_sources", "banned", "countries", "recipient", "file_path", "sent", "attempts", "error", "updated_at", }), }). Create(rec).Error } func (s *securityReports) Recent(ctx context.Context, n int) ([]*SecurityReportLog, error) { if n <= 0 { n = 12 } var rows []*SecurityReportLog err := s.db.WithContext(ctx).Order("window_end DESC").Limit(n).Find(&rows).Error return rows, err }