// 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 report 生成周期性安全状况汇总(周报 / 月报)。 // // 它回答的问题与蜜罐告警不同:告警是「刚刚有人在你门口试钥匙」,报告是 // 「这一周一共有多少人在试、从哪些国家来、哪几个网段最执着、我们在拦什么」。 // // 产物分三份,各有各的用途: // - 邮件正文里的 PNG 气泡地图:邮件客户端不执行 JS、多数还会剥离内联 SVG, // 只有位图能保证「打开就看得到」; // - 附件 HTML:完整报告(排行表 + 内联 SVG 地图),离线可看; // - 附件 HTML 里的腾讯地图:配了 key 才有,可缩放、可点开看明细。 // // ⚠️ 地图的合规边界:本包自己画的地图**只画经纬网与气泡,不画任何国界**。 // 国界、台湾、南海诸岛的绘制必须符合国家标准,自绘底图极容易画错, // 所以自绘图一律不含政治边界;需要真实底图时用腾讯地图(底图由持牌厂商提供)。 package report import ( "fmt" "time" ) // 周期类型。 const ( KindWeek = "week" KindMonth = "month" ) // Period 一个报告周期,时间窗口为 [Start, End)。 // // 左闭右开是刻意的:相邻两期既不能重叠(同一条记录算两次),也不能漏掉 // 恰好落在边界整点上的记录。 type Period struct { Kind string Key string Start time.Time End time.Time } // Label 返回人类可读的窗口描述,如 "2026-10-19 ~ 2026-10-25"。 func (p Period) Label() string { // End 是开区间端点,展示时退一秒落回周期内的最后一天,避免出现 // 「10-19 ~ 10-26」这种让人以为多算了一天的区间。 last := p.End.Add(-time.Second) if p.Kind == KindMonth { return p.Start.Format("2006-01") } return fmt.Sprintf("%s ~ %s", p.Start.Format("2006-01-02"), last.Format("2006-01-02")) } // Title 返回报告标题用的中文描述。 func (p Period) Title() string { if p.Kind == KindMonth { return p.Start.Format("2006 年 1 月") + "安全状况月报" } _, week := p.End.Add(-time.Second).ISOWeek() return fmt.Sprintf("%s 第 %d 周安全状况周报", p.Start.Format("2006 年 1 月"), week) } // Filename 返回报告文件名(不含扩展名)。 func (p Period) Filename() string { if p.Kind == KindMonth { return "security-month-" + p.Start.Format("2006-01") } return "security-week-" + p.Start.Format("2006-01-02") } // startOfWeek 返回 t 所在周的周一 00:00(本地时区)。 func startOfWeek(t time.Time) time.Time { wd := int(t.Weekday()) // Sunday=0 … Saturday=6 if wd == 0 { wd = 7 } y, m, d := t.Date() return time.Date(y, m, d, 0, 0, 0, 0, t.Location()).AddDate(0, 0, -(wd - 1)) } // startOfMonth 返回 t 所在月的 1 日 00:00。 func startOfMonth(t time.Time) time.Time { y, m, _ := t.Date() return time.Date(y, m, 1, 0, 0, 0, 0, t.Location()) } // PreviousWeek 返回 now 之前的**完整**一周。 // // 口径是「最近一个已结束的自然周(周一起算)」:在周一凌晨跑,拿到的是上周; // 在周三跑(补发),拿到的仍然是上周而不是「本周至今」—— // 报告必须覆盖完整周期,否则两次运行之间数字对不上。 func PreviousWeek(now time.Time) Period { end := startOfWeek(now) start := end.AddDate(0, 0, -7) // 用周期内最后一天算 ISO 周号:直接用 end 会被算成下一周。 y, w := end.Add(-time.Second).ISOWeek() return Period{ Kind: KindWeek, Key: fmt.Sprintf("week:%04d-W%02d", y, w), Start: start, End: end, } } // PreviousMonth 返回 now 之前的完整一个自然月。 func PreviousMonth(now time.Time) Period { end := startOfMonth(now) start := end.AddDate(0, -1, 0) return Period{ Kind: KindMonth, Key: "month:" + start.Format("2006-01"), Start: start, End: end, } }