feat(stats): 新增公开数据页 /stats,入口收敛到页脚

首页底部那张「站点统计」卡片下线,完整数据移到公开只读页 /stats:
今日实时数、概览六格、近 30 天折线与 12 个月柱状、三个占比环、
7×24 时段热力图、提问字数与回答耗时分布。

- 聚合在 internal/db/analytics.go(SUM(CASE WHEN),方言差异收敛在 analyticsDialectFor)
- 结果按自然日缓存,跨日重算一次后全站共用;重算失败降级返回上一份快照
- 图表零前端依赖:服务端算坐标的 SVG + CSS grid,禁用 JS 页面仍完整
- 环比取「上月 vs 上上月」:本月未过完,否则每月 1 号会得出 -95%
- 入口只在页脚「社区规则」与「找站长」之间;route.Home 不再计算统计
- 补 questions(created_at) 索引;启动后异步预热当天快照
- 测试:analytics 连真库验证软删除不计入、分桶下标、DOW 对齐
This commit is contained in:
tamakyi
2026-10-24 00:57:08 +08:00
parent c680671b7e
commit a5441c99fd
25 changed files with 2280 additions and 80 deletions
+303
View File
@@ -0,0 +1,303 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package stats
import (
"context"
"sync"
"time"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/db"
)
// Overview 是「全站数据」页的展示模型。
//
// 与 View 的区别:View 是首页那张三行小卡片的口径(30 分钟缓存、只有三个累计数),
// Overview 是完整聚合(趋势、构成、热力图、内容分布),按自然日缓存 —— 也就是
// 「每天更新一次」:当天第一次访问重算,之后全站共用同一份结果。
type Overview struct {
// 累计概览
RegisteredUsers int64
TotalQuestions int64
Answered int64
Public int64
Private int64
Read int64
Unread int64
Anonymous int64
LoggedIn int64
ActiveBoxes int64
AnsweringBoxes int64
TotalMessages int64
LastQuestionAt string
LastAnswerAt string
AnswerRate float64 // 已回答 / 总提问,百分比
FastReplyRate float64 // 24 小时内获得回答的比例,百分比
Total30 int64 // 近 30 天提问总数
AvgDailyLast30 float64 // 近 30 天日均提问
GrowthMoM float64 // 本月提问量相对上月的环比,百分比(上月为 0 时置 0)
HasPrevMonth bool // 上月是否有数据,决定要不要展示环比
// 今日实时(不参与每日缓存)
TodayQuestions int64
TodayAnswers int64
// 趋势(已补齐空日期,值为 0 的日期也占位,避免折线跳过)
Daily []DailyPoint
Monthly []MonthlyPoint
DailyMax int64
MonthlyMax int64
PeakDay DailyPoint
BusiestWD int // 0=周日
BusiestHr int // 0-23
// 热力图
Heatmap [7][24]int64
HeatmapMax int64
HeatmapDays int
// 内容分布
LengthBins [6]int64
ReplyBins [6]int64
// 元信息
UpdatedAt string
DataDate string
}
// DailyPoint 某一天的提问量。
type DailyPoint struct {
Date string // 2026-10-01
Label string // 10-01
N int64
}
// MonthlyPoint 某个月的提问量。
type MonthlyPoint struct {
Month string // 2026-10
Label string // 10
N int64
Current bool // 是否为当月(页面高亮最后一根柱子)
}
const (
dailyWindowDays = 30
monthlyWindow = 12
)
var (
ovMu sync.Mutex
ovCached *Overview
ovDay string
// ovRefresh 串行化重算:一天的第一个请求承担全部聚合,其余请求等它算完直接复用,
// 避免多个并发请求同时打一轮全表 GROUP BY(1C1G 上会互相拖慢)。
ovRefresh sync.Mutex
)
// OverviewData 返回全站数据页所需的聚合结果,按自然日缓存。
//
// 聚合失败时若手上还有昨天的结果,就继续返回旧的(页面照常打开,只是数据停在昨天);
// 完全没有可用结果才返回错误。
func OverviewData(ctx context.Context) (*Overview, error) {
today := time.Now().Format("2006-01-02")
ovMu.Lock()
cached, day := ovCached, ovDay
ovMu.Unlock()
if cached != nil && day == today {
return cached, nil
}
ovRefresh.Lock()
defer ovRefresh.Unlock()
// 二次检查:等锁期间可能已有别的请求刷新过。
ovMu.Lock()
cached, day = ovCached, ovDay
ovMu.Unlock()
if cached != nil && day == today {
return cached, nil
}
if db.Analytics == nil {
// 单测里没有初始化数据库时不应 panic,交给调用方回退。
return nil, errNoDatabase
}
now := time.Now()
raw, err := db.Analytics.SiteAnalytics(ctx, now)
if err != nil {
logrus.WithContext(ctx).WithError(err).Warn("计算全站统计数据失败")
if cached != nil {
return cached, nil
}
return nil, err
}
v := buildOverview(raw, now)
ovMu.Lock()
ovCached = v
ovDay = today
ovMu.Unlock()
return v, nil
}
// InvalidateOverview 丢弃缓存,供需要立即看到新数字的场景调用。
func InvalidateOverview() {
ovMu.Lock()
ovCached = nil
ovDay = ""
ovMu.Unlock()
}
var errNoDatabase = errString("database not initialized")
type errString string
func (e errString) Error() string { return string(e) }
func buildOverview(raw *db.SiteAnalytics, now time.Time) *Overview {
v := &Overview{
RegisteredUsers: raw.RegisteredUsers,
TotalQuestions: raw.TotalQuestions,
Answered: raw.Answered,
Public: raw.TotalQuestions - raw.Private,
Private: raw.Private,
Read: raw.Read,
Unread: raw.TotalQuestions - raw.Read,
Anonymous: raw.Anonymous,
LoggedIn: raw.TotalQuestions - raw.Anonymous,
ActiveBoxes: raw.ActiveBoxes,
AnsweringBoxes: raw.AnsweringBoxes,
TotalMessages: raw.TotalMessages,
TodayQuestions: raw.TodayQuestions,
TodayAnswers: raw.TodayAnswers,
Heatmap: raw.Heatmap,
HeatmapDays: raw.HeatmapDays,
LengthBins: raw.LengthBins,
ReplyBins: raw.ReplyBins,
DataDate: now.Format("2006-01-02"),
}
if v.Public < 0 {
v.Public = 0
}
if v.Unread < 0 {
v.Unread = 0
}
if v.LoggedIn < 0 {
v.LoggedIn = 0
}
if raw.LastQuestionAt != nil {
v.LastQuestionAt = raw.LastQuestionAt.Format("2006-01-02 15:04:05")
}
if raw.LastAnswerAt != nil {
v.LastAnswerAt = raw.LastAnswerAt.Format("2006-01-02 15:04:05")
}
if v.TotalQuestions > 0 {
v.AnswerRate = percent(v.Answered, v.TotalQuestions)
}
if answered := v.Answered; answered > 0 {
v.FastReplyRate = percent(v.ReplyBins[0]+v.ReplyBins[1]+v.ReplyBins[2], answered)
}
v.Daily = buildDaily(raw.Daily, now, dailyWindowDays)
v.Monthly = buildMonthly(raw.Monthly, now, monthlyWindow)
for _, p := range v.Daily {
if p.N > v.DailyMax {
v.DailyMax = p.N
}
if p.N >= v.PeakDay.N {
v.PeakDay = p
}
}
var dailySum int64
for _, p := range v.Daily {
dailySum += p.N
}
v.Total30 = dailySum
v.AvgDailyLast30 = float64(dailySum) / float64(dailyWindowDays)
for _, p := range v.Monthly {
if p.N > v.MonthlyMax {
v.MonthlyMax = p.N
}
}
// 环比取「上月 vs 上上月」而不是「本月 vs 上月」:本月通常还没过完,
// 拿一个残月去比完整月份,每月 1 号都会得出 -95% 这种毫无意义的结论。
if n := len(v.Monthly); n >= 3 && v.Monthly[n-3].N > 0 {
prev := float64(v.Monthly[n-3].N)
v.HasPrevMonth = true
v.GrowthMoM = (float64(v.Monthly[n-2].N) - prev) / prev * 100
}
for _, row := range v.Heatmap {
for _, n := range row {
if n > v.HeatmapMax {
v.HeatmapMax = n
}
}
}
v.BusiestWD, v.BusiestHr = busiestSlot(v.Heatmap)
v.UpdatedAt = now.Format("2006-01-02 15:04:05")
return v
}
// buildDaily 把「只有有数据的那几天」补齐成连续 N 天,折线才不会在无数据的日期断开。
func buildDaily(raw []db.CountPoint, now time.Time, days int) []DailyPoint {
index := make(map[string]int64, len(raw))
for _, p := range raw {
index[p.Key] = p.N
}
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).
AddDate(0, 0, -(days - 1))
out := make([]DailyPoint, 0, days)
for i := 0; i < days; i++ {
d := start.AddDate(0, 0, i)
key := d.Format("2006-01-02")
out = append(out, DailyPoint{Date: key, Label: d.Format("01-02"), N: index[key]})
}
return out
}
func buildMonthly(raw []db.CountPoint, now time.Time, months int) []MonthlyPoint {
index := make(map[string]int64, len(raw))
for _, p := range raw {
index[p.Key] = p.N
}
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).
AddDate(0, -(months - 1), 0)
out := make([]MonthlyPoint, 0, months)
for i := 0; i < months; i++ {
m := start.AddDate(0, i, 0)
key := m.Format("2006-01")
out = append(out, MonthlyPoint{Month: key, Label: m.Format("01"), N: index[key], Current: i == months-1})
}
return out
}
// busiestSlot 找出提问最集中的「星期 × 小时」。同值时取更早的下标,结果稳定可复现。
func busiestSlot(heatmap [7][24]int64) (weekday, hour int) {
best := int64(-1)
for d := 0; d < 7; d++ {
for h := 0; h < 24; h++ {
if heatmap[d][h] > best {
best = heatmap[d][h]
weekday, hour = d, h
}
}
}
return weekday, hour
}
func percent(part, total int64) float64 {
if total <= 0 {
return 0
}
return float64(part) / float64(total) * 100
}
+107
View File
@@ -0,0 +1,107 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package stats
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/db"
)
// TestBuildDailyFillsGaps 没有数据的日期必须占位,否则折线会把两天连成一条直线,
// 让人以为「那天也有提问」。
func TestBuildDailyFillsGaps(t *testing.T) {
now := time.Date(2026, 10, 23, 15, 30, 0, 0, time.Local)
got := buildDaily([]db.CountPoint{{Key: "2026-10-20", N: 5}}, now, 5)
require.Len(t, got, 5)
require.Equal(t, "2026-10-19", got[0].Date)
require.Equal(t, "2026-10-23", got[4].Date)
require.EqualValues(t, 0, got[0].N)
require.EqualValues(t, 5, got[1].N, "10-20 是窗口里的第 2 天")
}
// TestBuildMonthlyNoMonthOverflow 月底不能溢出:3-31 直接 AddDate(0,-11,0) 会滚到 3 月初,
// 12 根柱子就变成 11 根。实现里先归到当月 1 号,这里守住这个行为。
func TestBuildMonthlyNoMonthOverflow(t *testing.T) {
now := time.Date(2026, 3, 31, 0, 0, 0, 0, time.Local)
got := buildMonthly(nil, now, 12)
require.Len(t, got, 12)
require.Equal(t, "2025-04", got[0].Month)
require.Equal(t, "2026-03", got[11].Month)
require.True(t, got[11].Current, "最后一根柱子是当月")
require.False(t, got[0].Current)
}
// TestBusiestSlot 同值时取更早的下标,保证同一份数据每次算出来的结论一致。
func TestBusiestSlot(t *testing.T) {
var heatmap [7][24]int64
heatmap[2][9] = 10
heatmap[5][20] = 10
wd, hr := busiestSlot(heatmap)
require.Equal(t, 2, wd)
require.Equal(t, 9, hr)
}
// TestOverviewEmptyNoDivideByZero 全零数据不能除零,也不能编出不存在的结论。
func TestOverviewEmptyNoDivideByZero(t *testing.T) {
v := buildOverview(&db.SiteAnalytics{}, time.Now())
require.Zero(t, v.AnswerRate)
require.Zero(t, v.FastReplyRate)
require.Zero(t, v.GrowthMoM)
require.False(t, v.HasPrevMonth)
require.Len(t, v.Daily, dailyWindowDays)
require.Len(t, v.Monthly, monthlyWindow)
page := NewPage("zh-CN", v)
require.Len(t, page.Insights, 1, "空库只说一句「数据还太少」")
require.Equal(t, "提问数据还太少,暂时看不出什么。", page.Insights[0])
}
// TestOverviewDerived 派生指标:回答率、环比、最忙时段、24 小时内回复占比。
func TestOverviewDerived(t *testing.T) {
now := time.Now()
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
prevMonth := monthStart.AddDate(0, -1, 0).Format("2006-01")
monthBefore := monthStart.AddDate(0, -2, 0).Format("2006-01")
raw := &db.SiteAnalytics{
TotalQuestions: 200, Answered: 50, Private: 20,
Anonymous: 120,
Daily: []db.CountPoint{{Key: now.Format("2006-01-02"), N: 7}},
// 环比看的是「上月」与「上上月」:本月还没过完,当年 10 月为 0 会把结论带偏。
Monthly: []db.CountPoint{
{Key: monthBefore, N: 100},
{Key: prevMonth, N: 50},
},
LengthBins: [6]int64{1, 2, 3, 4, 5, 6},
ReplyBins: [6]int64{10, 10, 10, 10, 5, 5},
}
raw.Heatmap[1][8] = 40
v := buildOverview(raw, now)
require.InDelta(t, 25.0, v.AnswerRate, 0.001)
require.InDelta(t, 60.0, v.FastReplyRate, 0.001, "(10+10+10)/50 在 24 小时内")
require.Equal(t, 180, int(v.Public))
require.Equal(t, 80, int(v.LoggedIn))
require.Equal(t, 1, v.BusiestWD)
require.Equal(t, 8, v.BusiestHr)
require.EqualValues(t, 40, v.HeatmapMax)
require.True(t, v.HasPrevMonth)
require.InDelta(t, -50.0, v.GrowthMoM, 0.001, "9 月 50 相对 8 月 100 是 -50%")
page := NewPage("zh-CN", v)
require.NotEmpty(t, page.Insights)
require.Len(t, page.WeekdayLabels, 7)
require.Len(t, page.LengthLabels, 6)
require.Len(t, page.ReplyLabels, 6)
}
+152
View File
@@ -0,0 +1,152 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package stats
import (
"fmt"
"math"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// Page 是 /stats 模板的渲染模型:在 Overview 之上补齐多语言标签、
// 图形需要的百分比,以及自动生成的文字结论。
//
// 为什么标签在这一层拼而不是模板里拼:桶标签是按序号取的(stats.len.3 之类),
// 模板里 printf 拼 key 一旦缺词条就会把 key 本身印到页面上;集中在这里拼,
// 缺词条时页面最多少一行文字,不会露出内部 key。
type Page struct {
*Overview
WeekdayLabels []string
LengthLabels []string
ReplyLabels []string
Insights []string
DailyValues []int64
MonthlyValues []int64
// HeatmapRows 按「周一 … 周日」重排后的热力图(Overview 里以周日为下标 0)。
HeatmapRows [7][24]int64
MonthlyPcts []float64
LengthPcts []float64
ReplyPcts []float64
}
// weekdayOrder 热力图与图例的展示顺序:从周一排到周日(数据库的 DOW 以周日为 0)。
var weekdayOrder = []int{1, 2, 3, 4, 5, 6, 0}
// NewPage 把聚合结果转成模板可直接渲染的模型。
func NewPage(lang string, ov *Overview) *Page {
p := &Page{Overview: ov}
for i := range weekdayOrder {
p.WeekdayLabels = append(p.WeekdayLabels, i18n.T(lang, fmt.Sprintf("stats.wd.%d", i+1)))
}
for i := 1; i <= len(ov.LengthBins); i++ {
p.LengthLabels = append(p.LengthLabels, i18n.T(lang, fmt.Sprintf("stats.len.%d", i)))
}
for i := 1; i <= len(ov.ReplyBins); i++ {
p.ReplyLabels = append(p.ReplyLabels, i18n.T(lang, fmt.Sprintf("stats.reply.%d", i)))
}
for _, d := range ov.Daily {
p.DailyValues = append(p.DailyValues, d.N)
}
for _, m := range ov.Monthly {
p.MonthlyValues = append(p.MonthlyValues, m.N)
}
for i, d := range weekdayOrder {
p.HeatmapRows[i] = ov.Heatmap[d]
}
p.MonthlyPcts = toPercents(p.MonthlyValues)
p.LengthPcts = toPercents(ov.LengthBins[:])
p.ReplyPcts = toPercents(ov.ReplyBins[:])
p.Insights = insights(lang, ov)
return p
}
// toPercents 把一组计数换算成相对最大值的百分比,供 CSS 直接当宽度 / 高度用。
func toPercents(values []int64) []float64 {
var max int64
for _, n := range values {
if n > max {
max = n
}
}
out := make([]float64, len(values))
if max == 0 {
return out
}
for i, n := range values {
out[i] = float64(n) / float64(max) * 100
}
return out
}
// insights 生成几句人话结论。
//
// 只陈述能从聚合数字直接读到的事实(最忙的一天、最集中的时段、环比、回复速度),
// 不做趋势预测、也不写「大家更活跃了」这类评价 —— 支撑那种判断的数据这张表里没有。
func insights(lang string, ov *Overview) []string {
t := func(key string, args ...interface{}) string { return i18n.T(lang, key, args...) }
out := make([]string, 0, 6)
if ov.TotalQuestions == 0 {
return append(out, t("stats.insight.empty"))
}
out = append(out, t("stats.insight.daily30",
numText(ov.Total30), math.Round(ov.AvgDailyLast30*10)/10))
if ov.PeakDay.N > 0 {
out = append(out, t("stats.insight.peak", ov.PeakDay.Label, numText(ov.PeakDay.N)))
}
if ov.HeatmapMax > 0 {
// DOW 以周日为 0,展示顺序以周一为 1,换算到 stats.wd.N。
wd := (ov.BusiestWD+6)%7 + 1
out = append(out, t("stats.insight.busiest",
i18n.T(lang, fmt.Sprintf("stats.wd.%d", wd)), ov.BusiestHr))
}
if ov.HasPrevMonth {
out = append(out, t("stats.insight.mom", fmt.Sprintf("%+.1f", ov.GrowthMoM)))
}
if ov.Answered > 0 {
out = append(out, t("stats.insight.fastReply", fmt.Sprintf("%.1f", ov.FastReplyRate)))
}
out = append(out, t("stats.insight.private",
fmt.Sprintf("%.1f", 100-float64(ov.Public)/float64(ov.TotalQuestions)*100)))
return out
}
// numText 加千分位(2148 → "2,148")。
//
// 模板侧有同名功能的 NumInt,但它在 internal/template 里;统计层反过来依赖渲染层
// 会让依赖方向变脏,所以这里重新写一份十行的实现。
func numText(v int64) string {
s := fmt.Sprintf("%d", v)
if len(s) <= 3 {
return s
}
var b []byte
lead := len(s) % 3
if lead > 0 {
b = append(b, s[:lead]...)
}
for i := lead; i < len(s); i += 3 {
if len(b) > 0 {
b = append(b, ',')
}
b = append(b, s[i:i+3]...)
}
return string(b)
}