Files
tamabox/internal/db/analytics_test.go
T
tamakyi a5441c99fd 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 对齐
2026-10-24 00:57:08 +08:00

137 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}