Files
tamabox/internal/stats/page.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

153 lines
4.4 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 (
"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)
}