// 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 insights 为箱主生成「自己的数据面板」。 // // 与 internal/stats 的分工:stats 是全站公开页(任何人都能看、不含单箱维度、 // 结果按自然日缓存);本包只看当前登录用户自己的箱,数据实时,只对箱主可见。 package insights import ( "context" "fmt" "math" "time" "github.com/sirupsen/logrus" "github.com/tamakyi/TamaBox/internal/db" "github.com/tamakyi/TamaBox/internal/i18n" ) const ( // TrendDays 趋势窗口天数(含今天)。 TrendDays = 30 // WeekDays「本周」窗口与「上周」对比窗口的长度。 WeekDays = 7 ) // Page 是箱主数据面板的渲染模型。 type Page struct { Lang string // HasViews 表示是否已经有任何一条访问记录。新箱子的面板会显示引导文案, // 而不是一排 0 —— 后者看起来像统计坏了。 HasViews bool Since time.Time Today Window Week Window Month Window All Window Days []Day // Hours 24 小时来访分布(近 30 天的浏览)。 Hours [24]Hour PeakHour int PeakHourN int64 Funnel *db.BoxFunnel // 派生指标(百分比,0~100)。 Conversion float64 // 近 30 天「提问数 / UV」 AnswerRate float64 // 全期回答率 FastReply float64 // 全期 24 小时内回答占比 BestDay *Day // AnswerRateText 是 AnswerRate 的展示形式:整数不带小数点(100%,而不是 100.0%)。 AnswerRateText string // Conclusions 自动生成的一两句话结论,已本地化。 Conclusions []string } // Window 一段时间内的汇总。 type Window struct { PV int64 UV int64 Questions int64 Answers int64 // PrevPV / HasPrev 用于跟上一个等长周期比较(只有 Week 有值)。 PrevPV int64 HasPrev bool // Delta 相对 PrevPV 的变化百分比。PrevPV 为 0 时无意义,用 HasDelta 标记。 Delta float64 HasDelta bool } // Day 趋势图里的一天。 type Day struct { Key string // 2026-10-29 Label string // 10-29 Weekday string PV int64 UV int64 Questions int64 Answers int64 // 高度百分比(0~100),直接当 CSS 高度用。 PVHeight float64 UVHeight float64 QHeight float64 IsToday bool IsEmpty bool } // Hour 时段分布里的一个柱子。 type Hour struct { Hour int N int64 Height float64 } // Build 装配箱主 userID 的数据面板。 // // 任何一个子查询失败都只让对应区块缺数据,不返回错误:面板是「看个大概」的页面, // 因为一条聚合 SQL 挂了就整页 500,对箱主没有任何帮助。 func Build(ctx context.Context, lang string, userID uint, now time.Time) *Page { p := &Page{Lang: lang, Funnel: &db.BoxFunnel{}} today := dayOnly(now) monthFrom := today.AddDate(0, 0, -(TrendDays - 1)) weekFrom := today.AddDate(0, 0, -(WeekDays - 1)) prevWeekFrom := today.AddDate(0, 0, -(2*WeekDays - 1)) prevWeekTo := today.AddDate(0, 0, -WeekDays) // 先无条件铺好 30 天空骨架:访问统计和提问统计任何一侧缺数据, // 趋势图都还能画出坐标轴与空柱,而不是整块消失。 p.buildDays(nil, today) if db.BoxViews != nil { days, err := db.BoxViews.Daily(ctx, userID, monthFrom, today) if err != nil { logrus.WithContext(ctx).WithError(err).Warn("加载箱主访问趋势失败") } else { p.fillViews(days) } if pv, uv, err := db.BoxViews.Totals(ctx, userID); err != nil { logrus.WithContext(ctx).WithError(err).Warn("加载箱主累计访问失败") } else { p.All.PV, p.All.UV = pv, uv p.HasViews = pv > 0 } if since, err := db.BoxViews.FirstDay(ctx, userID); err != nil { logrus.WithContext(ctx).WithError(err).Warn("加载箱主首次访问时间失败") } else { p.Since = since } } if db.BoxAnalytics != nil { if trend, err := db.BoxAnalytics.BoxTrend(ctx, userID, monthFrom, today); err != nil { logrus.WithContext(ctx).WithError(err).Warn("加载箱主提问趋势失败") } else { p.fillQuestions(trend) } if hours, err := db.BoxAnalytics.BoxHours(ctx, userID, monthFrom); err != nil { logrus.WithContext(ctx).WithError(err).Warn("加载箱主时段分布失败") } else { p.buildHours(hours) } if funnel, err := db.BoxAnalytics.BoxFunnel(ctx, userID); err != nil { logrus.WithContext(ctx).WithError(err).Warn("加载箱主提问漏斗失败") } else { p.Funnel = funnel } } p.aggregateWindows(today, weekFrom, prevWeekFrom, prevWeekTo) p.computeRates() return p } // dayOnly 截断到当天 0 点,保留传入时区。 func dayOnly(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) } // buildDays 铺出完整的 30 天序列(不含任何计数)。 func (p *Page) buildDays(_ []db.BoxViewDay, today time.Time) { from := today.AddDate(0, 0, -(TrendDays - 1)) p.Days = make([]Day, 0, TrendDays) for i := 0; i < TrendDays; i++ { d := from.AddDate(0, 0, i) p.Days = append(p.Days, Day{ Key: d.Format("2006-01-02"), Label: d.Format("01-02"), Weekday: fmt.Sprintf("stats.wd.%d", int(d.Weekday())), IsToday: sameDay(d, today), IsEmpty: true, }) } } // fillViews 把访问量填进已有骨架。 func (p *Page) fillViews(rows []db.BoxViewDay) { index := make(map[string]int, len(p.Days)) for i, d := range p.Days { index[d.Key] = i } for _, r := range rows { if i, ok := index[r.Day.Format("2006-01-02")]; ok { p.Days[i].PV = r.PV p.Days[i].UV = r.UV p.Days[i].IsEmpty = r.PV == 0 && p.Days[i].Questions == 0 } } p.recomputeHeights() } // fillQuestions 把提问 / 回答数填进已有骨架。 func (p *Page) fillQuestions(rows []db.BoxDayCount) { index := make(map[string]int, len(p.Days)) for i, d := range p.Days { index[d.Key] = i } for _, r := range rows { if i, ok := index[r.Day.Format("2006-01-02")]; ok { p.Days[i].Questions = r.Questions p.Days[i].Answers = r.Answers p.Days[i].IsEmpty = p.Days[i].PV == 0 && r.Questions == 0 } } p.recomputeHeights() } // recomputeHeights 按窗口内最大值归一化成百分比高度。 // // 最大值为 0 时全部置 0(而不是除零),模板会把柱高 0 渲染成一条淡淡的底线。 func (p *Page) recomputeHeights() { var maxPV, maxQ int64 for _, d := range p.Days { if d.PV > maxPV { maxPV = d.PV } if d.UV > maxPV { maxPV = d.UV } if d.Questions > maxQ { maxQ = d.Questions } } for i := range p.Days { d := &p.Days[i] if maxPV > 0 { d.PVHeight = pct(d.PV, maxPV) d.UVHeight = pct(d.UV, maxPV) } if maxQ > 0 { d.QHeight = pct(d.Questions, maxQ) } } } func (p *Page) buildHours(counts [24]int64) { var max int64 for _, n := range counts { if n > max { max = n } } for h := 0; h < 24; h++ { p.Hours[h] = Hour{Hour: h, N: counts[h]} if max > 0 { p.Hours[h].Height = pct(counts[h], max) } if counts[h] > p.PeakHourN { p.PeakHourN = counts[h] p.PeakHour = h } } } // aggregateWindows 从日序列切出今日 / 本周 / 本月窗口,并算上周对比。 func (p *Page) aggregateWindows(today, weekFrom, prevFrom, prevTo time.Time) { for _, d := range p.Days { t, err := time.ParseInLocation("2006-01-02", d.Key, today.Location()) if err != nil { continue } p.Month.PV += d.PV p.Month.UV += d.UV p.Month.Questions += d.Questions p.Month.Answers += d.Answers if !t.Before(weekFrom) { p.Week.PV += d.PV p.Week.UV += d.UV p.Week.Questions += d.Questions p.Week.Answers += d.Answers } if !t.Before(prevFrom) && !t.After(prevTo) { p.Week.PrevPV += d.PV } if sameDay(t, today) { p.Today.PV = d.PV p.Today.UV = d.UV p.Today.Questions = d.Questions p.Today.Answers = d.Answers } if p.BestDay == nil || d.PV > p.BestDay.PV { cp := d p.BestDay = &cp } } // 全期提问 / 回答来自漏斗(它扫的是整张表,不受 30 天窗口限制)。 if p.Funnel != nil { p.All.Questions = p.Funnel.Total p.All.Answers = p.Funnel.Answered } p.Week.HasPrev = true if p.Week.PrevPV > 0 { p.Week.HasDelta = true p.Week.Delta = float64(p.Week.PV-p.Week.PrevPV) / float64(p.Week.PrevPV) * 100 } // 上周一次访问都没有时,「涨了 100%」这类结论毫无意义 —— 直接不显示对比。 } func (p *Page) computeRates() { if p.Month.UV > 0 { p.Conversion = float64(p.Month.Questions) / float64(p.Month.UV) * 100 } if p.Funnel != nil && p.Funnel.Total > 0 { p.AnswerRate = float64(p.Funnel.Answered) / float64(p.Funnel.Total) * 100 if p.Funnel.Answered > 0 { p.FastReply = float64(p.Funnel.RepliedIn24h) / float64(p.Funnel.Answered) * 100 } } p.AnswerRateText = formatPct(p.AnswerRate) } // formatPct 把百分比渲染成「100%」或「33.3%」——整数值不补 .0。 // // 面板上「100.0%」这种写法会让人下意识去找那个小数位,而它并不携带信息。 func formatPct(v float64) string { if v == math.Trunc(v) { return fmt.Sprintf("%.0f", v) } return fmt.Sprintf("%.1f", v) } // pct 返回 n 相对 max 的百分比,保留一位小数。 func pct(n, max int64) float64 { if max <= 0 { return 0 } v := float64(n) / float64(max) * 100 // 四舍五入到 0.1,避免出现一长串小数把 CSS 撑得很长。 return float64(int(v*10+0.5)) / 10 } func sameDay(a, b time.Time) bool { ay, am, ad := a.Date() by, bm, bd := b.Date() return ay == by && am == bm && ad == bd } // formatDay 把日期格式化成「10-29」这种短标签,供文案使用。 func formatDay(t time.Time) string { if t.IsZero() { return "" } return t.Format("01-02") } // FormatInsights 生成面板顶部的自动结论。 // // 放在这里而不是模板里:结论需要做「有没有数据」「除零」这类判断, // 全塞进模板会让它变成一堆 eq/if 的嵌套。 func (p *Page) FormatInsights() { lang := p.Lang add := func(key string, args ...interface{}) { text := i18n.T(lang, key, args...) // 缺词条时 T 会返回 key 本身,别把内部 key 印到页面上。 if text == "" || text == key { return } p.Conclusions = append(p.Conclusions, text) } if !p.HasViews && p.Funnel.Total == 0 { add("insights.insight.empty") return } if p.HasViews { add("insights.insight.weekViews", formatInt(p.Week.PV), formatInt(p.Week.UV)) } // 样本太小的时候「转化率」没有解释力:1 位访客提了 1 个问题就是 100%, // 这种数字只会误导箱主,不如不说。 if p.Month.UV >= 5 && p.Month.Questions > 0 { add("insights.insight.conversion", formatPct(p.Conversion)) } if p.BestDay != nil && p.BestDay.PV > 0 { add("insights.insight.bestDay", p.BestDay.Label, formatInt(p.BestDay.PV)) } if p.PeakHourN > 0 { add("insights.insight.peakHour", p.PeakHour) } if p.Funnel.Total > 0 { add("insights.insight.answerRate", formatPct(p.AnswerRate)) } if p.FastReply > 0 { add("insights.insight.fastReply", fmt.Sprintf("%.0f", p.FastReply)) } if p.Funnel.Unread > 0 { add("insights.insight.unread", formatInt(p.Funnel.Unread)) } if p.Week.HasDelta { switch { case p.Week.Delta >= 5: add("insights.insight.trendUp", fmt.Sprintf("%.0f", p.Week.Delta)) case p.Week.Delta <= -5: add("insights.insight.trendDown", fmt.Sprintf("%.0f", -p.Week.Delta)) default: add("insights.insight.trendFlat") } } } // formatInt 给数字加千分位。 func formatInt(n int64) string { if n < 0 { return "-" + formatInt(-n) } s := fmt.Sprintf("%d", n) if len(s) <= 3 { return s } var out []byte for i, c := range []byte(s) { if i > 0 && (len(s)-i)%3 == 0 { out = append(out, ',') } out = append(out, c) } return string(out) }