首页底部那张「站点统计」卡片下线,完整数据移到公开只读页 /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 对齐
108 lines
3.7 KiB
Go
108 lines
3.7 KiB
Go
// 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)
|
|
}
|