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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user