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
+136
View File
@@ -0,0 +1,136 @@
// 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 db
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestSiteAnalytics 验证 /stats 页的聚合口径。
//
// 这里刻意连真库跑而不是塞 mock:整段都是数据库方言相关的 SQL
// (to_char / EXTRACT / CHAR_LENGTH / SUM(CASE WHEN)),
// 对着 mock 断言等于什么都没验证。
func TestSiteAnalytics(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
t.Cleanup(func() {
if err := cleanup("users", "questions", "messages"); err != nil {
t.Fatal(err)
}
})
ctx := context.Background()
now := time.Now()
store := NewSiteAnalyticsStore(db)
questions := NewQuestionsStore(db)
require.NoError(t, db.Create(&User{UID: "u1", Name: "box", Domain: "boxa", Email: "[email protected]"}).Error)
// 一条登录用户提的、已回答的长问题
answered, err := questions.Create(ctx, CreateQuestionOptions{
UserID: 1, Content: strings.Repeat("字", 120), FromIP: "1.1.1.1", AskerUserID: 7,
})
require.NoError(t, err)
require.NoError(t, questions.AnswerByID(ctx, answered.ID, "答案"))
// 一条匿名提的私密短问题,未读未答
_, err = questions.Create(ctx, CreateQuestionOptions{
UserID: 1, Content: "短问题", FromIP: "1.1.1.2", IsPrivate: true,
})
require.NoError(t, err)
// 一条被软删除的:不该出现在任何数字里
deleted, err := questions.Create(ctx, CreateQuestionOptions{
UserID: 1, Content: "会被删掉", FromIP: "1.1.1.3",
})
require.NoError(t, err)
require.NoError(t, questions.DeleteByID(ctx, deleted.ID))
got, err := store.SiteAnalytics(ctx, now)
require.NoError(t, err)
// 计数
require.EqualValues(t, 1, got.RegisteredUsers)
require.EqualValues(t, 2, got.TotalQuestions, "软删除的提问不能计入总数")
require.EqualValues(t, 1, got.Answered)
require.EqualValues(t, 1, got.Private)
require.EqualValues(t, 1, got.Read, "已回答的提问恒为已读")
require.EqualValues(t, 1, got.Anonymous)
require.EqualValues(t, 1, got.ActiveBoxes)
require.EqualValues(t, 1, got.AnsweringBoxes)
// 今日(提问是刚才写的,必然落在今天)
require.EqualValues(t, 2, got.TodayQuestions)
require.EqualValues(t, 1, got.TodayAnswers)
// 日/月趋势里能找到今天与本月
today := now.Format("2006-01-02")
require.EqualValues(t, 2, countOf(got.Daily, today))
require.EqualValues(t, 2, countOf(got.Monthly, now.Format("2006-01")))
// 热力图:两条提问都发生在当前这一小时
dow := int(now.Weekday()) // Go 的 Weekday 与 EXTRACT(DOW) 都是周日 0
require.EqualValues(t, 2, got.Heatmap[dow][now.Hour()])
// 字数分桶:120 字落在 100–199(下标 3),"短问题" 3 字落在 <20(下标 0)
require.EqualValues(t, 1, got.LengthBins[0])
require.EqualValues(t, 1, got.LengthBins[3])
// 回答耗时:刚回答完,落在 1 小时内(下标 0);未回答的不计入
require.EqualValues(t, 1, got.ReplyBins[0])
var replyTotal int64
for _, n := range got.ReplyBins {
replyTotal += n
}
require.EqualValues(t, 1, replyTotal, "耗时分布只统计已回答的提问")
// 最后时间戳
require.NotNil(t, got.LastQuestionAt)
require.NotNil(t, got.LastAnswerAt)
}
// TestSiteAnalyticsEmpty 空库不应报错,也不应把零值算成异常。
func TestSiteAnalyticsEmpty(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
t.Cleanup(func() {
if err := cleanup("users", "questions", "messages"); err != nil {
t.Fatal(err)
}
})
got, err := NewSiteAnalyticsStore(db).SiteAnalytics(context.Background(), time.Now())
require.NoError(t, err)
require.Zero(t, got.TotalQuestions)
require.Zero(t, got.RegisteredUsers)
require.Nil(t, got.LastQuestionAt)
require.Nil(t, got.LastAnswerAt)
var heatTotal int64
for _, row := range got.Heatmap {
for _, n := range row {
heatTotal += n
}
}
require.Zero(t, heatTotal)
}
func countOf(points []CountPoint, key string) int64 {
for _, p := range points {
if p.Key == key {
return p.N
}
}
return 0
}