feat(honeypot): 蜜罐安全状况周期汇总(周报/月报 + 地图 + 邮件)

以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。

新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
  答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
  「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
  (一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
  才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
  钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
  每次运行精修到不同 IP,配额花在长尾上。

报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
  相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
  路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
  必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
  标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
  看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
  代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。

存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
  失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
  defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)

配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。

顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
  TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
This commit is contained in:
tamakyi
2026-10-30 16:40:44 +08:00
parent 08bbd17751
commit d690355cdc
33 changed files with 5198 additions and 1 deletions
+4
View File
@@ -45,6 +45,10 @@ var AllTables = []interface{}{
&UserRestriction{}, &ContentReport{},
// 蜜罐命中记录(审计证据,只追加不软删)
&HoneypotHit{},
// 来源 IP 的地理定位缓存(安全汇总报告用,纯 KV 缓存)
&IPGeoRecord{},
// 周期安全汇总的报告台账(period_key 唯一,保证重启不会重复发信)
&SecurityReportLog{},
// 自动封禁记录(内存态封禁的落库账本,用于事后解释与取证)
&BanRecord{},
// 箱主数据面板的访问流水(每 访客×天×箱 一行,Unscoped 语义,见 BoxView 注释)
+77
View File
@@ -97,6 +97,20 @@ type HoneypotStore interface {
// 而不是只能靠开发一次性汇报。Bytes 取整个关系的总大小(含索引),
// 因为磁盘压力来自索引和 TOAST,不只是堆表。
Size(ctx context.Context) (HoneypotSize, error)
// AggregateSources 统计 [since, until) 内按来源 IP 聚合的命中数。
//
// 安全汇总报告专用:报告要先按 IP 聚合再拿去地理定位,而不是把几十万条
// 命中明细全捞进内存再 group —— 明细只在「命中数」这一个维度上有用,
// 聚合能把它压到「独立 IP 数」的量级。
AggregateSources(ctx context.Context, since, until time.Time) ([]*HoneypotSourceAgg, error)
// AggregatePaths 统计 [since, until) 内被探测最多的路径。
AggregatePaths(ctx context.Context, since, until time.Time, limit int) ([]*HoneypotPathStat, error)
// TimestampsBetween 返回 [since, until) 内所有命中的时间戳(仅这一列)。
//
// 用于画每日趋势。刻意不在 SQL 里按天分组:DATE() 的返回类型在
// PostgreSQL(date)与 MySQL([]byte)之间不一致,跨方言解析很脆;
// 而这一列的体积极小(时间戳 8 字节),取回来在 Go 里按天归并更稳。
TimestampsBetween(ctx context.Context, since, until time.Time) ([]time.Time, error)
// LegacyCredentialColumns 返回仍残留的旧版凭据列(空表示已清理)。
LegacyCredentialColumns(ctx context.Context) ([]string, error)
// PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。
@@ -121,6 +135,20 @@ type HoneypotSourceStat struct {
Provider string
}
// HoneypotSourceAgg 是带「处置结果」的来源聚合行(安全汇总报告用)。
//
// 与 HoneypotSourceStat 的区别:那个是给 CLI 排行看的(只要次数),
// 这个要给报告回答「打了多少次 / 有多少次被拦下 / 有多少次真的在试密码」。
type HoneypotSourceAgg struct {
IP string
Hits int64
Banned int64
// Creds 带有凭据字段的命中数(真实破解尝试,区别于纯路径探测)。
Creds int64
// Provider 是该 IP 最近一次命中时的 cdncheck 标签(云厂商/CDN/WAF)。
Provider string
}
// HoneypotPathStat 路径聚合行。
type HoneypotPathStat struct {
Path string
@@ -234,6 +262,55 @@ func (h *honeypots) TopPaths(ctx context.Context, since time.Time, limit int) ([
return rows, err
}
// AggregateSources 统计 [since, until) 内按来源 IP 聚合的命中数。
//
// [since, until) 左闭右开:周期报告的相邻两期不能有重叠,也不能漏掉
// 恰好落在整点上的记录 —— 用闭区间会让周一的 00:00:00 同时算进上周和本周。
func (h *honeypots) AggregateSources(ctx context.Context, since, until time.Time) ([]*HoneypotSourceAgg, error) {
var rows []*HoneypotSourceAgg
err := h.db.WithContext(ctx).
Table("honeypot_hits").
// SUM(CASE WHEN ...) 是 PG 与 MySQL 都支持的写法(boolean 在 PG 里
// 不能直接 sum,转 1/0 才跨方言)。
Select("ip AS ip, count(*) AS hits, "+
"sum(case when banned then 1 else 0 end) AS banned, "+
"sum(case when had_fields then 1 else 0 end) AS creds, "+
"max(provider) AS provider").
Where("created_at >= ? AND created_at < ?", since, until).
Group("ip").
Order("hits DESC").
Scan(&rows).Error
return rows, err
}
// AggregatePaths 统计 [since, until) 内被探测最多的路径。
func (h *honeypots) AggregatePaths(ctx context.Context, since, until time.Time, limit int) ([]*HoneypotPathStat, error) {
if limit <= 0 || limit > 500 {
limit = 20
}
var rows []*HoneypotPathStat
err := h.db.WithContext(ctx).
Table("honeypot_hits").
Select("path AS path, count(*) AS count").
Where("created_at >= ? AND created_at < ?", since, until).
Group("path").
Order("count DESC").
Limit(limit).
Scan(&rows).Error
return rows, err
}
// TimestampsBetween 返回 [since, until) 内所有命中的时间戳。
func (h *honeypots) TimestampsBetween(ctx context.Context, since, until time.Time) ([]time.Time, error) {
var ts []time.Time
err := h.db.WithContext(ctx).
Model(&HoneypotHit{}).
Where("created_at >= ? AND created_at < ?", since, until).
Order("created_at ASC").
Pluck("created_at", &ts).Error
return ts, err
}
func (h *honeypots) DeleteBefore(ctx context.Context, before time.Time) (int64, error) {
res := h.db.WithContext(ctx).Where("created_at < ?", before).Delete(&HoneypotHit{})
return res.RowsAffected, res.Error
+70
View File
@@ -159,3 +159,73 @@ func TestHoneypotSize(t *testing.T) {
require.NoError(t, err)
require.Equal(t, int64(2), info2.Rows)
}
// TestHoneypotAggregateRange 覆盖安全汇总报告用的区间聚合。
//
// 三个方法共同回答「这一周谁在打、打了哪儿」。重点验证:
// - [since, until) 左闭右开,相邻两期不能重叠;
// - SUM(CASE WHEN ...) 在 PostgreSQL / MySQL 上都能算出处置数;
// - 只取时间戳那一列时不夹带整行(报告不需要 UA / 路径以外的字段)。
func TestHoneypotAggregateRange(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("honeypot_hits"); err != nil {
t.Fatal(err)
}
})
store := NewHoneypotStore(database)
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 7)
rows := []*HoneypotHit{
// 窗口内:IP A 三次(两次被拦、一次带凭据)、IP B 一次
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, HadFields: false, Provider: "Azure", CreatedAt: start.Add(time.Hour)},
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, HadFields: true, Provider: "Azure", CreatedAt: start.Add(2 * time.Hour)},
{IP: "203.0.113.1", Path: "/.env", Banned: true, HadFields: false, Provider: "Azure", CreatedAt: start.Add(3 * time.Hour)},
{IP: "198.51.100.2", Path: "/wp-admin", Banned: false, HadFields: false, CreatedAt: start.Add(4 * time.Hour)},
// 窗口外(右侧开区间边界):不能算进本期
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, CreatedAt: end},
// 窗口外(早于起点)
{IP: "192.0.2.9", Path: "/phpmyadmin", Banned: true, CreatedAt: start.Add(-time.Minute)},
}
for _, r := range rows {
require.NoError(t, store.Create(ctx, r))
}
sources, err := store.AggregateSources(ctx, start, end)
require.NoError(t, err)
require.Len(t, sources, 2)
// 按命中数降序:203.0.113.1 在前
require.Equal(t, "203.0.113.1", sources[0].IP)
require.Equal(t, int64(3), sources[0].Hits)
require.Equal(t, int64(3), sources[0].Banned)
require.Equal(t, int64(1), sources[0].Creds)
require.Equal(t, "Azure", sources[0].Provider)
require.Equal(t, "198.51.100.2", sources[1].IP)
require.Equal(t, int64(1), sources[1].Hits)
require.Equal(t, int64(0), sources[1].Banned)
paths, err := store.AggregatePaths(ctx, start, end, 10)
require.NoError(t, err)
require.Len(t, paths, 3)
require.Equal(t, "/wp-login.php", paths[0].Path)
require.Equal(t, int64(2), paths[0].Count)
stamps, err := store.TimestampsBetween(ctx, start, end)
require.NoError(t, err)
require.Len(t, stamps, 4, "右边界那条不能算进来")
for _, ts := range stamps {
require.True(t, ts.Before(end), "所有时间戳都应严格早于 end")
}
// 空窗口必须返回空而不是报错(报告要能生成「本周无人扫描」)
empty, err := store.AggregateSources(ctx, start.AddDate(0, 0, 30), start.AddDate(0, 0, 37))
require.NoError(t, err)
require.Empty(t, empty)
}
+151
View File
@@ -0,0 +1,151 @@
// 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"
)
// IPGeoRecord 是一个来源 IP 的地理定位缓存。
//
// 为什么必须落库而不是每次现查:
// - 免费 IP 库都有配额(ip-api 免费版 45 次/分钟)。一次周报可能有几百上千个
// 不同来源 IP,现查必然撞限流,撞了就是「报告里一半 IP 没有位置」;
// - 同一个攻击者会在多份报告里反复出现,缓存能让第二次之后零外部请求;
// - 定位结果本身变化很慢(国家级几乎不变,城市级极少变),没必要重复付费。
//
// 缓存键用 shared.LimitKey(IPv4 精确、IPv6 折叠 /64)而不是原始 IP:
// 攻击者常在一个 /64 里轮换地址,按精确地址缓存会把同一个 C 段查上几十次,
// 既浪费配额又得到一模一样的结果。
type IPGeoRecord struct {
// IPKey 是缓存键(shared.LimitKey 的产物)。主键,不用自增 id:
// 这张表是纯 KV 缓存,没有任何按 id 遍历或引用的需求。
IPKey string `gorm:"size:64;primarykey"`
Country string `gorm:"size:64"`
CountryCode string `gorm:"size:8"`
Region string `gorm:"size:128"`
City string `gorm:"size:128"`
// ISP 运营商 / 网络名("China Telecom"、"Amazon.com, Inc.")。肉鸡报告里
// 这个字段比国家更有信息量:能看出是家宽还是云主机。
ISP string `gorm:"size:160"`
// ASN 自治域号("AS4134")。同 ASN 大量命中通常意味着同一片被控网络。
ASN string `gorm:"size:64"`
// Lat / Lon 为定位坐标。坐标系随来源不同:
// tencent → GCJ-02(火星坐标,与腾讯地图对齐)
// ipapi → WGS-84
// 两者相差几百米,在世界地图气泡图尺度上无影响;放大到街道级才有意义。
Lat float64
Lon float64
// HasCoords 标记坐标是否可用。IP 库对国家级、部分省级结果不给坐标,
// 用 0,0 表示「未知」会被画到几内亚湾去 —— 必须显式区分。
HasCoords bool
// Source 记录这条数据来自哪个库(tencent / ipapi),便于事后判断精度。
Source string `gorm:"size:16"`
// Failed 标记查询失败。失败也要缓存:某个 IP 查不出来通常是确定性的
// (保留地址、库未收录),反复重试只会白白消耗配额。
Failed bool
ResolvedAt time.Time `gorm:"index"`
}
// TableName 固定表名。
func (IPGeoRecord) TableName() string { return "ip_geo_cache" }
// IPGeos 地理定位缓存全局 store。
var IPGeos IPGeoStore
// IPGeoStore 地理定位缓存的读写。
type IPGeoStore interface {
// GetMany 批量取缓存,返回 map[IPKey]*IPGeoRecord。未命中的键不出现在结果里。
GetMany(ctx context.Context, keys []string) (map[string]*IPGeoRecord, error)
// UpsertMany 批量写入或覆盖。
UpsertMany(ctx context.Context, records []*IPGeoRecord) error
// PurgeResolvedBefore 删除 resolved_at 早于 before 的记录(缓存过期)。
//
// 注意「过期」与「失败」的 TTL 不同,由调用方分两次调:
// 成功结果留得久(90 天),失败结果留得短(3 天)—— 失败可能是临时故障
// (对方 API 超时),留太久等于永久放弃这个 IP。
PurgeResolvedBefore(ctx context.Context, before time.Time, onlyFailed bool) (int64, error)
// Count 返回缓存条目数。
Count(ctx context.Context) (int64, error)
}
type ipGeos struct {
db *gorm.DB
}
// NewIPGeoStore 构造缓存 store。务必传 db.DB 而不是复用什么包级单例。
func NewIPGeoStore(db *gorm.DB) IPGeoStore {
return &ipGeos{db: db}
}
func (s *ipGeos) GetMany(ctx context.Context, keys []string) (map[string]*IPGeoRecord, error) {
out := make(map[string]*IPGeoRecord, len(keys))
if len(keys) == 0 {
return out, nil
}
// 分批 IN 查询:PostgreSQL 的占位符上限是 65535,MySQL 受 max_allowed_packet
// 约束,一次塞上千个键虽然多数情况能过,但没必要赌。
const batch = 500
for start := 0; start < len(keys); start += batch {
end := start + batch
if end > len(keys) {
end = len(keys)
}
var rows []*IPGeoRecord
if err := s.db.WithContext(ctx).
Where("ip_key IN ?", keys[start:end]).
Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
out[r.IPKey] = r
}
}
return out, nil
}
func (s *ipGeos) UpsertMany(ctx context.Context, records []*IPGeoRecord) error {
if len(records) == 0 {
return nil
}
// 只更新定位字段,不更新 ip_key 本身(它是冲突判定列)。
cols := []string{
"country", "country_code", "region", "city", "isp", "asn",
"lat", "lon", "has_coords", "source", "failed", "resolved_at",
}
return s.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "ip_key"}},
DoUpdates: clause.AssignmentColumns(cols),
}).
CreateInBatches(records, 100).Error
}
func (s *ipGeos) PurgeResolvedBefore(ctx context.Context, before time.Time, onlyFailed bool) (int64, error) {
q := s.db.WithContext(ctx).Where("resolved_at < ?", before)
if onlyFailed {
q = q.Where("failed = ?", true)
} else {
// 成功记录那一轮不要连失败的一起删:两者的 TTL 不同。
q = q.Where("failed = ?", false)
}
res := q.Delete(&IPGeoRecord{})
return res.RowsAffected, res.Error
}
func (s *ipGeos) Count(ctx context.Context) (int64, error) {
var n int64
err := s.db.WithContext(ctx).Model(&IPGeoRecord{}).Count(&n).Error
return n, err
}
+174
View File
@@ -0,0 +1,174 @@
// 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"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestIPGeoStore 覆盖地理定位缓存的读写、批量取、覆盖与过期清理。
//
// 这张表是配额的保护层:同一批攻击者会在多份报告里反复出现,
// 「查一次、之后全靠缓存」能不能成立全看这里的语义对不对。
func TestIPGeoStore(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("ip_geo_cache"); err != nil {
t.Fatal(err)
}
})
store := NewIPGeoStore(database)
now := time.Now()
records := []*IPGeoRecord{
{
IPKey: "203.0.113.1", Country: "美国", CountryCode: "US",
Region: "Virginia", City: "Ashburn", ISP: "Amazon", ASN: "AS14618",
Lat: 39.04, Lon: -77.49, HasCoords: true, Source: "ipapi", ResolvedAt: now,
},
{
IPKey: "2001:db8::/64", Country: "中国", CountryCode: "CN",
Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4,
HasCoords: true, Source: "tencent", ResolvedAt: now,
},
{
// 查不到:Failed 标记要能落库,否则每次报告都会重试同一批地址
IPKey: "198.51.100.9", Failed: true, ResolvedAt: now,
},
}
require.NoError(t, store.UpsertMany(ctx, records))
got, err := store.GetMany(ctx, []string{"203.0.113.1", "2001:db8::/64", "198.51.100.9", "10.0.0.1"})
require.NoError(t, err)
require.Len(t, got, 3, "不存在的键不应出现在结果里")
require.Equal(t, "Ashburn", got["203.0.113.1"].City)
require.Equal(t, "AS14618", got["203.0.113.1"].ASN)
require.True(t, got["203.0.113.1"].HasCoords)
require.Equal(t, "tencent", got["2001:db8::/64"].Source)
require.True(t, got["198.51.100.9"].Failed)
// 覆盖写:同一个键再写一次应当更新而不是报主键冲突
require.NoError(t, store.UpsertMany(ctx, []*IPGeoRecord{{
IPKey: "203.0.113.1", Country: "美国", CountryCode: "US",
City: "Reston", Lat: 38.96, Lon: -77.35, HasCoords: true,
Source: "tencent", ResolvedAt: now.Add(time.Hour),
}}))
got2, err := store.GetMany(ctx, []string{"203.0.113.1"})
require.NoError(t, err)
require.Equal(t, "Reston", got2["203.0.113.1"].City)
require.Equal(t, "tencent", got2["203.0.113.1"].Source)
n, err := store.Count(ctx)
require.NoError(t, err)
require.Equal(t, int64(3), n)
}
// TestIPGeoStorePurge 覆盖两套 TTL 分开清理:成功留 90 天、失败留 3 天。
func TestIPGeoStorePurge(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("ip_geo_cache"); err != nil {
t.Fatal(err)
}
})
store := NewIPGeoStore(database)
now := time.Now()
require.NoError(t, store.UpsertMany(ctx, []*IPGeoRecord{
{IPKey: "1.0.0.1", Country: "澳大利亚", ResolvedAt: now.AddDate(0, 0, -100)}, // 成功且过期
{IPKey: "1.0.0.2", Country: "日本", ResolvedAt: now.AddDate(0, 0, -10)}, // 成功且新鲜
{IPKey: "1.0.0.3", Failed: true, ResolvedAt: now.AddDate(0, 0, -4)}, // 失败且过期
{IPKey: "1.0.0.4", Failed: true, ResolvedAt: now}, // 失败且新鲜
}))
// 清失败的旧记录:只删 1.0.0.3
n, err := store.PurgeResolvedBefore(ctx, now.AddDate(0, 0, -3), true)
require.NoError(t, err)
require.Equal(t, int64(1), n)
// 清成功的旧记录:只删 1.0.0.1(不能连失败的一起删,两者 TTL 不同)
n, err = store.PurgeResolvedBefore(ctx, now.AddDate(0, 0, -90), false)
require.NoError(t, err)
require.Equal(t, int64(1), n)
left, err := store.GetMany(ctx, []string{"1.0.0.1", "1.0.0.2", "1.0.0.3", "1.0.0.4"})
require.NoError(t, err)
require.Len(t, left, 2)
require.Contains(t, left, "1.0.0.2")
require.Contains(t, left, "1.0.0.4")
}
// TestSecurityReportStore 覆盖报告台账的幂等语义。
//
// 这是「重启不会重复发同一期周报」的唯一保证:period_key 唯一索引 + upsert。
func TestSecurityReportStore(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("security_report_logs"); err != nil {
t.Fatal(err)
}
})
store := NewSecurityReportStore(database)
start := time.Date(2026, 10, 19, 0, 0, 0, 0, time.Local)
end := start.AddDate(0, 0, 7)
// 首次:不存在
rec, err := store.Get(ctx, "week:2026-W43")
require.NoError(t, err)
require.Nil(t, rec)
require.NoError(t, store.Save(ctx, &SecurityReportLog{
PeriodKey: "week:2026-W43", Kind: "week",
WindowStart: start, WindowEnd: end,
Hits: 42, UniqueSources: 7, Countries: 3,
Recipient: "[email protected]", Attempts: 1, Error: "smtp timeout",
}))
rec, err = store.Get(ctx, "week:2026-W43")
require.NoError(t, err)
require.NotNil(t, rec)
require.False(t, rec.Sent)
require.Equal(t, 1, rec.Attempts)
// 第二次:同一 period_key 再写应当覆盖,而不是插入第二行 ——
// 否则「这一期发过没」会被多行记录搞乱。
require.NoError(t, store.Save(ctx, &SecurityReportLog{
PeriodKey: "week:2026-W43", Kind: "week",
WindowStart: start, WindowEnd: end,
Hits: 42, UniqueSources: 7, Countries: 3,
Recipient: "[email protected]", Attempts: 2, Sent: true,
}))
rec, err = store.Get(ctx, "week:2026-W43")
require.NoError(t, err)
require.True(t, rec.Sent)
require.Equal(t, 2, rec.Attempts)
recent, err := store.Recent(ctx, 10)
require.NoError(t, err)
require.Len(t, recent, 1, "同一期只能有一行台账")
// 不同周期应各自一行
require.NoError(t, store.Save(ctx, &SecurityReportLog{
PeriodKey: "month:2026-10", Kind: "month",
WindowStart: start, WindowEnd: end, Sent: true,
}))
recent, err = store.Recent(ctx, 10)
require.NoError(t, err)
require.Len(t, recent, 2)
}
+118
View File
@@ -0,0 +1,118 @@
// 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
}