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:
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/tamakyi/TamaBox/internal/route"
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
"github.com/tamakyi/TamaBox/internal/stats"
|
||||
"github.com/tamakyi/TamaBox/internal/tracing"
|
||||
)
|
||||
|
||||
@@ -100,6 +101,16 @@ func runWeb(ctx *cli.Context) error {
|
||||
r := route.New()
|
||||
r.Use(tracing.Middleware("TamaBox"))
|
||||
|
||||
// 全站数据页预热:整页聚合是若干次全表 GROUP BY,若摊在当天第一个访客身上,
|
||||
// 首屏要白等一秒以上。这里延后几秒在后台先算一次,把等待挪出请求路径。
|
||||
// 失败只告警:页面在真正被访问时会自己重算并(若仍失败)回退到上一次成功的结果。
|
||||
go func() {
|
||||
time.Sleep(3 * time.Second)
|
||||
if _, err := stats.OverviewData(ctx.Context); err != nil {
|
||||
logrus.WithError(err).Warn("预热全站统计数据失败")
|
||||
}
|
||||
}()
|
||||
|
||||
// 最外层挂载 gzip 压缩,并在 http.Server 上配置超时:
|
||||
// - ReadHeaderTimeout 防止慢速客户端占用连接
|
||||
// - ReadTimeout / WriteTimeout 防止读写挂死
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
// 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"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SiteAnalyticsStore 提供「全站公开数据页」所需的聚合查询。
|
||||
//
|
||||
// 与 internal/stats 的分工:本层只做数据库聚合(纯查询,不缓存、不生成文案),
|
||||
// 缓存、补齐空日期与展示模型都在 stats 层。这样聚合 SQL 可以脱离 HTTP 单测。
|
||||
type SiteAnalyticsStore interface {
|
||||
SiteAnalytics(ctx context.Context, now time.Time) (*SiteAnalytics, error)
|
||||
}
|
||||
|
||||
// Analytics 全站聚合查询的包级入口,在 Init 中随其他 store 一起初始化。
|
||||
var Analytics SiteAnalyticsStore
|
||||
|
||||
// SiteAnalytics 是一次全站聚合的原始结果,所有计数都是「累及至今」或「窗口内」的口径,
|
||||
// 具体窗口由调用方传入的 now 决定(见 stats 包的 Overview)。
|
||||
type SiteAnalytics struct {
|
||||
RegisteredUsers int64
|
||||
TotalQuestions int64
|
||||
Answered int64
|
||||
Private int64
|
||||
Read int64
|
||||
Anonymous int64
|
||||
ActiveBoxes int64 // 至少收到过一条提问的箱主数
|
||||
AnsweringBoxes int64 // 至少回答过一条提问的箱主数
|
||||
TotalMessages int64
|
||||
LastQuestionAt *time.Time
|
||||
LastAnswerAt *time.Time
|
||||
|
||||
Daily []CountPoint // 近 30 天(按日)
|
||||
Monthly []CountPoint // 近 12 个月(按月)
|
||||
|
||||
// Heatmap 一周 7 天 × 24 小时的提问量,下标 0 为周日(与 EXTRACT(DOW) 对齐)。
|
||||
Heatmap [7][24]int64
|
||||
HeatmapDays int // Heatmap 的统计窗口天数,用于在页面上说明口径
|
||||
|
||||
LengthBins [6]int64 // 提问字数分布
|
||||
ReplyBins [6]int64 // 回答耗时分布(仅统计已回答的提问)
|
||||
|
||||
TodayQuestions int64
|
||||
TodayAnswers int64
|
||||
}
|
||||
|
||||
// CountPoint 一个分组键上的计数,Key 已由数据库格式化成可直接展示的字符串。
|
||||
type CountPoint struct {
|
||||
Key string
|
||||
N int64
|
||||
}
|
||||
|
||||
func NewSiteAnalyticsStore(db *gorm.DB) SiteAnalyticsStore {
|
||||
return &siteAnalytics{db}
|
||||
}
|
||||
|
||||
type siteAnalytics struct {
|
||||
*gorm.DB
|
||||
}
|
||||
|
||||
const (
|
||||
// 趋势窗口:近 30 天按日、近 12 个月按月。
|
||||
analyticsDailyDays = 30
|
||||
analyticsMonths = 12
|
||||
// 热力图窗口:一年太贵(全表聚合),半年足以代表「全站习惯」。
|
||||
analyticsHeatmapDays = 180
|
||||
|
||||
// 字数分桶上限(左闭右开),最后一桶为 500 以上。
|
||||
lengthBinExpr = `CASE
|
||||
WHEN CHAR_LENGTH(content) < 20 THEN 0
|
||||
WHEN CHAR_LENGTH(content) < 50 THEN 1
|
||||
WHEN CHAR_LENGTH(content) < 100 THEN 2
|
||||
WHEN CHAR_LENGTH(content) < 200 THEN 3
|
||||
WHEN CHAR_LENGTH(content) < 500 THEN 4
|
||||
ELSE 5 END`
|
||||
)
|
||||
|
||||
// analyticsDialect 把两套数据库里含义相同、写法不同的表达式收敛到一处。
|
||||
//
|
||||
// 项目实际部署用 PostgreSQL(见 Init 的索引分支),MySQL 分支只是保留可用性:
|
||||
// 配置切到 MySQL 时不应整页 500,而应安静地少几个数。
|
||||
type analyticsDialect struct {
|
||||
day string
|
||||
month string
|
||||
dow string
|
||||
hour string
|
||||
replyMin string
|
||||
}
|
||||
|
||||
func analyticsDialectFor(db *gorm.DB) analyticsDialect {
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
return analyticsDialect{
|
||||
day: "DATE_FORMAT(created_at, '%Y-%m-%d')",
|
||||
month: "DATE_FORMAT(created_at, '%Y-%m')",
|
||||
dow: "(DAYOFWEEK(created_at) - 1)",
|
||||
hour: "HOUR(created_at)",
|
||||
replyMin: "TIMESTAMPDIFF(MINUTE, created_at, updated_at)",
|
||||
}
|
||||
}
|
||||
// PostgreSQL:EXTRACT 返回 numeric,显式转 int 以免扫描进 int64 时类型不匹配。
|
||||
return analyticsDialect{
|
||||
day: "to_char(created_at, 'YYYY-MM-DD')",
|
||||
month: "to_char(created_at, 'YYYY-MM')",
|
||||
dow: "CAST(EXTRACT(DOW FROM created_at) AS INTEGER)",
|
||||
hour: "CAST(EXTRACT(HOUR FROM created_at) AS INTEGER)",
|
||||
replyMin: "CAST(EXTRACT(EPOCH FROM (updated_at - created_at)) / 60 AS INTEGER)",
|
||||
}
|
||||
}
|
||||
|
||||
func (db *siteAnalytics) SiteAnalytics(ctx context.Context, now time.Time) (*SiteAnalytics, error) {
|
||||
loc := now.Location()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
dailySince := startOfDay.AddDate(0, 0, -(analyticsDailyDays - 1))
|
||||
// 先归到当月 1 号再回退,避免月底 AddDate 溢出(3-31 减 11 个月会滚到 3-03)。
|
||||
monthSince := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, -(analyticsMonths - 1), 0)
|
||||
heatmapSince := startOfDay.AddDate(0, 0, -analyticsHeatmapDays)
|
||||
|
||||
out := &SiteAnalytics{HeatmapDays: analyticsHeatmapDays}
|
||||
|
||||
if err := db.aggregateTotals(ctx, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.aggregateTrends(ctx, out, dailySince, monthSince); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.aggregateHeatmap(ctx, out, heatmapSince); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.aggregateContent(ctx, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.WithContext(ctx).Model(&User{}).Count(&out.RegisteredUsers).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "count registered users")
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Message{}).Count(&out.TotalMessages).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "count messages")
|
||||
}
|
||||
|
||||
// 今日数字单独查:它们不进「每天一次」的缓存窗口,页面顶部要显示实时值。
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Where("created_at >= ?", startOfDay).Count(&out.TodayQuestions).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "count today's questions")
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Where("answer <> '' AND updated_at >= ?", startOfDay).Count(&out.TodayAnswers).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "count today's answers")
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// aggregateTotals 用一条 SQL 取回所有「不分时间窗口」的计数。
|
||||
//
|
||||
// 用 SUM(CASE WHEN ...) 而不是 COUNT(*) FILTER (WHERE ...):前者 MySQL 也支持,
|
||||
// 后者只在 PostgreSQL 上可用。
|
||||
func (db *siteAnalytics) aggregateTotals(ctx context.Context, out *SiteAnalytics) error {
|
||||
var row struct {
|
||||
Total int64
|
||||
Answered int64
|
||||
Private int64
|
||||
Read int64
|
||||
Anonymous int64
|
||||
ActiveBoxes int64 `gorm:"column:active_boxes"`
|
||||
AnsweringBoxes int64 `gorm:"column:answering_boxes"`
|
||||
LastQ *time.Time `gorm:"column:last_q"`
|
||||
LastA *time.Time `gorm:"column:last_a"`
|
||||
}
|
||||
err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(`COUNT(*) AS total,
|
||||
COALESCE(SUM(CASE WHEN answer <> '' THEN 1 ELSE 0 END), 0) AS answered,
|
||||
COALESCE(SUM(CASE WHEN is_private THEN 1 ELSE 0 END), 0) AS private,
|
||||
COALESCE(SUM(CASE WHEN is_read OR answer <> '' THEN 1 ELSE 0 END), 0) AS read,
|
||||
COALESCE(SUM(CASE WHEN asker_user_id = 0 THEN 1 ELSE 0 END), 0) AS anonymous,
|
||||
COUNT(DISTINCT user_id) AS active_boxes,
|
||||
COUNT(DISTINCT CASE WHEN answer <> '' THEN user_id END) AS answering_boxes,
|
||||
MAX(created_at) AS last_q,
|
||||
MAX(CASE WHEN answer <> '' THEN updated_at END) AS last_a`).
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "aggregate question totals")
|
||||
}
|
||||
|
||||
out.TotalQuestions = row.Total
|
||||
out.Answered = row.Answered
|
||||
out.Private = row.Private
|
||||
out.Read = row.Read
|
||||
out.Anonymous = row.Anonymous
|
||||
out.ActiveBoxes = row.ActiveBoxes
|
||||
out.AnsweringBoxes = row.AnsweringBoxes
|
||||
out.LastQuestionAt = row.LastQ
|
||||
out.LastAnswerAt = row.LastA
|
||||
return nil
|
||||
}
|
||||
|
||||
// aggregateTrends 按日与按月各取一次计数。未被 GROUP BY 覆盖的空日期由 stats 层补齐。
|
||||
func (db *siteAnalytics) aggregateTrends(ctx context.Context, out *SiteAnalytics, dailySince, monthSince time.Time) error {
|
||||
d := analyticsDialectFor(db.DB)
|
||||
|
||||
var daily []struct {
|
||||
Key string
|
||||
N int64
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(d.day+" AS key, COUNT(*) AS n").
|
||||
Where("created_at >= ?", dailySince).
|
||||
Group(d.day).Order(d.day).
|
||||
Scan(&daily).Error; err != nil {
|
||||
return errors.Wrap(err, "aggregate daily trend")
|
||||
}
|
||||
for _, row := range daily {
|
||||
out.Daily = append(out.Daily, CountPoint{Key: row.Key, N: row.N})
|
||||
}
|
||||
|
||||
var monthly []struct {
|
||||
Key string
|
||||
N int64
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(d.month+" AS key, COUNT(*) AS n").
|
||||
Where("created_at >= ?", monthSince).
|
||||
Group(d.month).Order(d.month).
|
||||
Scan(&monthly).Error; err != nil {
|
||||
return errors.Wrap(err, "aggregate monthly trend")
|
||||
}
|
||||
for _, row := range monthly {
|
||||
out.Monthly = append(out.Monthly, CountPoint{Key: row.Key, N: row.N})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *siteAnalytics) aggregateHeatmap(ctx context.Context, out *SiteAnalytics, since time.Time) error {
|
||||
d := analyticsDialectFor(db.DB)
|
||||
|
||||
var rows []struct {
|
||||
Dow int
|
||||
Hr int
|
||||
N int64
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(d.dow+" AS dow, "+d.hour+" AS hr, COUNT(*) AS n").
|
||||
Where("created_at >= ?", since).
|
||||
Group(d.dow + ", " + d.hour).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return errors.Wrap(err, "aggregate weekday/hour heatmap")
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
// 越界值直接丢弃:MySQL 的 DAYOFWEEK 或异常时区数据都可能给出 7 以上的下标,
|
||||
// 落进固定数组会 panic,而这份数据只影响一张图,不值得让整页失败。
|
||||
if row.Dow < 0 || row.Dow > 6 || row.Hr < 0 || row.Hr > 23 {
|
||||
continue
|
||||
}
|
||||
out.Heatmap[row.Dow][row.Hr] = row.N
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *siteAnalytics) aggregateContent(ctx context.Context, out *SiteAnalytics) error {
|
||||
d := analyticsDialectFor(db.DB)
|
||||
|
||||
var lengths []struct {
|
||||
Bin int
|
||||
N int64
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(lengthBinExpr + " AS bin, COUNT(*) AS n").
|
||||
Group(lengthBinExpr).
|
||||
Scan(&lengths).Error; err != nil {
|
||||
return errors.Wrap(err, "aggregate content length distribution")
|
||||
}
|
||||
for _, row := range lengths {
|
||||
if row.Bin >= 0 && row.Bin < len(out.LengthBins) {
|
||||
out.LengthBins[row.Bin] = row.N
|
||||
}
|
||||
}
|
||||
|
||||
// 回答耗时:只用已回答的提问,按分钟分桶。
|
||||
// 口径注意:updated_at 也会被置顶等写操作刷新,因此这里算出的耗时偏大;
|
||||
// 展示时只呈现「24 小时内回答占比」这类粗粒度结论,不宣称精确中位数。
|
||||
replyBinExpr := fmt.Sprintf(`CASE
|
||||
WHEN %[1]s < 60 THEN 0
|
||||
WHEN %[1]s < 360 THEN 1
|
||||
WHEN %[1]s < 1440 THEN 2
|
||||
WHEN %[1]s < 4320 THEN 3
|
||||
WHEN %[1]s < 10080 THEN 4
|
||||
ELSE 5 END`, d.replyMin)
|
||||
|
||||
var replies []struct {
|
||||
Bin int
|
||||
N int64
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(replyBinExpr + " AS bin, COUNT(*) AS n").
|
||||
Where("answer <> ''").
|
||||
Group(replyBinExpr).
|
||||
Scan(&replies).Error; err != nil {
|
||||
return errors.Wrap(err, "aggregate reply latency distribution")
|
||||
}
|
||||
for _, row := range replies {
|
||||
if row.Bin >= 0 && row.Bin < len(out.ReplyBins) {
|
||||
out.ReplyBins[row.Bin] = row.N
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -127,6 +127,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
Restrictions = NewRestrictionsStore(db)
|
||||
Reports = NewReportsStore(db)
|
||||
Honeypots = NewHoneypotStore(db)
|
||||
Analytics = NewSiteAnalyticsStore(db)
|
||||
|
||||
// 旧版本把消息/密码明文存在 honeypot_hits 上;结构体已删掉这些字段,
|
||||
// 但 AutoMigrate 不会删除既有列 —— 老库升级上来后历史明文仍在。
|
||||
@@ -217,6 +218,9 @@ func dedupeUniqueFields(db *gorm.DB) error {
|
||||
func ensureFrequentlyUsedIndexes(db *gorm.DB) error {
|
||||
indexes := []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_questions_asker_user_id ON questions(asker_user_id)`,
|
||||
// 全站数据页(/stats):按 created_at 做范围扫描后按天/小时分组。
|
||||
// 没有这个索引时,趋势与热力图会退化成全表扫描 + 排序;加上之后是范围扫描 + 聚合。
|
||||
`CREATE INDEX IF NOT EXISTS idx_questions_created_at ON questions(created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_questions_user_private_created ON questions(user_id, is_private, created_at)`,
|
||||
// 「下一条未读」:user_id + is_read 过滤后按 created_at 取最早一条。
|
||||
// 上面那条中间隔着 is_private,帮不上这个排序。
|
||||
|
||||
@@ -951,4 +951,81 @@ var enDict = map[string]string{
|
||||
"theme.name.sakura": "Sakura",
|
||||
"theme.name.shiroko": "Shiroko",
|
||||
"theme.name.new-sakura": "New Sakura",
|
||||
|
||||
// Site statistics page (/stats): aggregate numbers only
|
||||
"site.footer.stats": "Stats",
|
||||
"stats.title": "Site statistics",
|
||||
"stats.lead": "Only aggregate numbers here: how many questions the site receives, how fast they get answered, and when people tend to show up. No individual question, asker identity, or source address is shown.",
|
||||
"stats.unavailable": "Statistics are temporarily unavailable. Please check back later.",
|
||||
|
||||
"stats.today.questions": "Asked today",
|
||||
"stats.today.answers": "Answered today",
|
||||
"stats.today.tag": "(live)",
|
||||
|
||||
"stats.kpi.users": "Registered users",
|
||||
"stats.kpi.questions": "Questions asked",
|
||||
"stats.kpi.answered": "Answered",
|
||||
"stats.kpi.answerRate": "Answer rate",
|
||||
"stats.kpi.activeBoxes": "Boxes that received questions",
|
||||
"stats.kpi.messages": "Direct messages",
|
||||
|
||||
"stats.insight.title": "At a glance",
|
||||
"stats.insight.note": "Each line below is read straight from the numbers above — no guesswork.",
|
||||
"stats.insight.empty": "Not enough question data yet.",
|
||||
"stats.insight.daily30": "%s questions in the last 30 days, about %.1f per day.",
|
||||
"stats.insight.peak": "Busiest day was %s with %s questions.",
|
||||
"stats.insight.busiest": "Questions cluster around %s %d:00.",
|
||||
"stats.insight.mom": "Last month versus the month before: %s%% questions.",
|
||||
"stats.insight.fastReply": "%s%% of answered questions got a reply within 24 hours.",
|
||||
"stats.insight.private": "%s%% of questions were sent privately.",
|
||||
|
||||
"stats.trend.dailyTitle": "Questions over the last 30 days",
|
||||
"stats.trend.dailyNote": "Daily question count.",
|
||||
"stats.trend.monthlyTitle": "Questions per month (last 12)",
|
||||
"stats.trend.monthlyNote": "The last bar is the current month, still in progress.",
|
||||
|
||||
"stats.mix.title": "Composition",
|
||||
"stats.mix.note": "Three ratios: public or private, answered or not, and whether the asker was signed in.",
|
||||
"stats.mix.public": "Public questions",
|
||||
"stats.mix.answered": "Answered",
|
||||
"stats.mix.login": "From signed-in users",
|
||||
|
||||
"stats.heat.title": "When questions arrive",
|
||||
"stats.heat.note": "Questions from the last %d days grouped by weekday and hour — darker means busier.",
|
||||
"stats.heat.less": "Less",
|
||||
"stats.heat.more": "More",
|
||||
|
||||
"stats.content.title": "Length and pace",
|
||||
"stats.content.note": "How long questions are, and how fast they get answered.",
|
||||
"stats.content.lengthTitle": "Question length",
|
||||
"stats.content.replyTitle": "Time to answer",
|
||||
|
||||
"stats.len.1": "under 20 chars",
|
||||
"stats.len.2": "20–49 chars",
|
||||
"stats.len.3": "50–99 chars",
|
||||
"stats.len.4": "100–199 chars",
|
||||
"stats.len.5": "200–499 chars",
|
||||
"stats.len.6": "500+ chars",
|
||||
|
||||
"stats.reply.1": "within 1 hour",
|
||||
"stats.reply.2": "1–6 hours",
|
||||
"stats.reply.3": "6–24 hours",
|
||||
"stats.reply.4": "1–3 days",
|
||||
"stats.reply.5": "3–7 days",
|
||||
"stats.reply.6": "over 7 days",
|
||||
|
||||
"stats.wd.1": "Mon",
|
||||
"stats.wd.2": "Tue",
|
||||
"stats.wd.3": "Wed",
|
||||
"stats.wd.4": "Thu",
|
||||
"stats.wd.5": "Fri",
|
||||
"stats.wd.6": "Sat",
|
||||
"stats.wd.7": "Sun",
|
||||
|
||||
"stats.foot.dataDate": "Data as of",
|
||||
"stats.foot.updated": "computed at",
|
||||
"stats.foot.prevMonth": "Last question:",
|
||||
"stats.foot.prevAnswer": "Last answer:",
|
||||
"stats.foot.privacy": "All figures are aggregates; no individual question, asker, or source address is shown.",
|
||||
"stats.foot.cacheNote": "Totals and charts are a snapshot taken at 00:00 local time, refreshed once a day; the two \"today\" figures are live.",
|
||||
}
|
||||
|
||||
@@ -950,4 +950,81 @@ var jaDict = map[string]string{
|
||||
"theme.name.sakura": "さくら",
|
||||
"theme.name.shiroko": "シロコ",
|
||||
"theme.name.new-sakura": "新さくら",
|
||||
|
||||
// サイト統計ページ(/stats):集計値のみ
|
||||
"site.footer.stats": "統計",
|
||||
"stats.title": "サイト統計",
|
||||
"stats.lead": "ここにあるのは集計値だけです。サイト全体にどれだけ質問が届き、どれくらいで回答され、いつ人が来るのか。個々の質問内容・質問者の身元・送信元アドレスは一切表示しません。",
|
||||
"stats.unavailable": "統計データを一時的に取得できません。しばらくしてから再度お試しください。",
|
||||
|
||||
"stats.today.questions": "今日の質問",
|
||||
"stats.today.answers": "今日の回答",
|
||||
"stats.today.tag": "(リアルタイム)",
|
||||
|
||||
"stats.kpi.users": "登録ユーザー",
|
||||
"stats.kpi.questions": "質問の総数",
|
||||
"stats.kpi.answered": "回答済み",
|
||||
"stats.kpi.answerRate": "回答率",
|
||||
"stats.kpi.activeBoxes": "質問を受け取った箱",
|
||||
"stats.kpi.messages": "DM の総数",
|
||||
|
||||
"stats.insight.title": "ひと目でわかる数字",
|
||||
"stats.insight.note": "以下の文はすべて上の数値から直接読み取ったもので、推測は含みません。",
|
||||
"stats.insight.empty": "まだ分析できるほどの質問データがありません。",
|
||||
"stats.insight.daily30": "直近 30 日で %s 件の質問、1 日あたり約 %.1f 件。",
|
||||
"stats.insight.peak": "最も多かった日は %s で %s 件でした。",
|
||||
"stats.insight.busiest": "質問は %s %d 時ごろに集中しています。",
|
||||
"stats.insight.mom": "先月は前月比 %s%%。",
|
||||
"stats.insight.fastReply": "回答済みのうち %s%% が 24 時間以内に回答されています。",
|
||||
"stats.insight.private": "質問の %s%% は非公開が選ばれています。",
|
||||
|
||||
"stats.trend.dailyTitle": "直近 30 日の質問数",
|
||||
"stats.trend.dailyNote": "1 日ごとの質問数です。",
|
||||
"stats.trend.monthlyTitle": "直近 12 か月の質問数",
|
||||
"stats.trend.monthlyNote": "最後の棒は当月で、まだ集計中です。",
|
||||
|
||||
"stats.mix.title": "内訳",
|
||||
"stats.mix.note": "3 つの割合:公開か非公開か、回答済みか、ログイン済みの利用者か。",
|
||||
"stats.mix.public": "公開の質問",
|
||||
"stats.mix.answered": "回答済み",
|
||||
"stats.mix.login": "ログイン利用者の質問",
|
||||
|
||||
"stats.heat.title": "質問が届く時間帯",
|
||||
"stats.heat.note": "直近 %d 日の質問を「曜日 × 時間」で集計。色が濃いほど集中しています。",
|
||||
"stats.heat.less": "少",
|
||||
"stats.heat.more": "多",
|
||||
|
||||
"stats.content.title": "長さと速さ",
|
||||
"stats.content.note": "質問の長さと、回答までの速さ。",
|
||||
"stats.content.lengthTitle": "質問の文字数",
|
||||
"stats.content.replyTitle": "回答までの時間",
|
||||
|
||||
"stats.len.1": "20 文字未満",
|
||||
"stats.len.2": "20–49 文字",
|
||||
"stats.len.3": "50–99 文字",
|
||||
"stats.len.4": "100–199 文字",
|
||||
"stats.len.5": "200–499 文字",
|
||||
"stats.len.6": "500 文字以上",
|
||||
|
||||
"stats.reply.1": "1 時間以内",
|
||||
"stats.reply.2": "1–6 時間",
|
||||
"stats.reply.3": "6–24 時間",
|
||||
"stats.reply.4": "1–3 日",
|
||||
"stats.reply.5": "3–7 日",
|
||||
"stats.reply.6": "7 日以上",
|
||||
|
||||
"stats.wd.1": "月",
|
||||
"stats.wd.2": "火",
|
||||
"stats.wd.3": "水",
|
||||
"stats.wd.4": "木",
|
||||
"stats.wd.5": "金",
|
||||
"stats.wd.6": "土",
|
||||
"stats.wd.7": "日",
|
||||
|
||||
"stats.foot.dataDate": "集計基準日:",
|
||||
"stats.foot.updated": "算出時刻",
|
||||
"stats.foot.prevMonth": "最後の質問:",
|
||||
"stats.foot.prevAnswer": "最後の回答:",
|
||||
"stats.foot.privacy": "数値はすべて集計値です。個々の質問内容・質問者の身元・送信元アドレスは表示しません。",
|
||||
"stats.foot.cacheNote": "累計値とグラフは当日 0 時のスナップショットで、1 日 1 回だけ更新されます。「今日」の 2 項目はリアルタイムです。",
|
||||
}
|
||||
|
||||
@@ -951,4 +951,81 @@ var zhCNDict = map[string]string{
|
||||
"form.register.recaptcha": "无法加载人机验证,请刷新页面或切换网络后重试",
|
||||
"form.register.agreeRules": "请先阅读并同意社区规则",
|
||||
"form.register.repeatPassword": "两次输入的密码不一致",
|
||||
|
||||
// 全站数据页(/stats):只展示聚合数字,不含任何单条内容
|
||||
"site.footer.stats": "数据",
|
||||
"stats.title": "全站数据",
|
||||
"stats.lead": "这里只有汇总后的数字:全站收到了多少提问、回答得有多快、大家习惯什么时候来。不含任何一条具体提问的内容、提问者身份或来源地址。",
|
||||
"stats.unavailable": "数据暂时不可用,稍后再刷新看看。",
|
||||
|
||||
"stats.today.questions": "今日提问",
|
||||
"stats.today.answers": "今日回答",
|
||||
"stats.today.tag": "(实时)",
|
||||
|
||||
"stats.kpi.users": "注册用户",
|
||||
"stats.kpi.questions": "累计提问",
|
||||
"stats.kpi.answered": "已回答",
|
||||
"stats.kpi.answerRate": "回答率",
|
||||
"stats.kpi.activeBoxes": "收到过提问的箱子",
|
||||
"stats.kpi.messages": "累计私聊消息",
|
||||
|
||||
"stats.insight.title": "数据速览",
|
||||
"stats.insight.note": "下面几句是从上面的数字直接读出来的,没有推测。",
|
||||
"stats.insight.empty": "提问数据还太少,暂时看不出什么。",
|
||||
"stats.insight.daily30": "近 30 天共收到 %s 条提问,日均 %.1f 条。",
|
||||
"stats.insight.peak": "最忙的一天是 %s,收到 %s 条提问。",
|
||||
"stats.insight.busiest": "提问最集中在 %s %d 点前后。",
|
||||
"stats.insight.mom": "上月提问量比前一个月 %s%%。",
|
||||
"stats.insight.fastReply": "已回答的提问中,%s%% 在 24 小时内拿到了回复。",
|
||||
"stats.insight.private": "%s%% 的提问选择了私密(不公开)。",
|
||||
|
||||
"stats.trend.dailyTitle": "近 30 天提问趋势",
|
||||
"stats.trend.dailyNote": "按天统计的提问量。",
|
||||
"stats.trend.monthlyTitle": "近 12 个月提问量",
|
||||
"stats.trend.monthlyNote": "最后一根柱子是当月,还没过完。",
|
||||
|
||||
"stats.mix.title": "构成",
|
||||
"stats.mix.note": "三组比例:公开还是私密、有没有被回答、提问者有没有登录。",
|
||||
"stats.mix.public": "公开提问",
|
||||
"stats.mix.answered": "已回答",
|
||||
"stats.mix.login": "登录用户提问",
|
||||
|
||||
"stats.heat.title": "一周内的提问时段",
|
||||
"stats.heat.note": "最近 %d 天的提问按「星期 × 小时」聚合,颜色越深表示越集中。",
|
||||
"stats.heat.less": "少",
|
||||
"stats.heat.more": "多",
|
||||
|
||||
"stats.content.title": "内容与节奏",
|
||||
"stats.content.note": "提问有多长,回答有多快。",
|
||||
"stats.content.lengthTitle": "提问字数分布",
|
||||
"stats.content.replyTitle": "回答耗时分布",
|
||||
|
||||
"stats.len.1": "20 字以内",
|
||||
"stats.len.2": "20–49 字",
|
||||
"stats.len.3": "50–99 字",
|
||||
"stats.len.4": "100–199 字",
|
||||
"stats.len.5": "200–499 字",
|
||||
"stats.len.6": "500 字以上",
|
||||
|
||||
"stats.reply.1": "1 小时内",
|
||||
"stats.reply.2": "1–6 小时",
|
||||
"stats.reply.3": "6–24 小时",
|
||||
"stats.reply.4": "1–3 天",
|
||||
"stats.reply.5": "3–7 天",
|
||||
"stats.reply.6": "7 天以上",
|
||||
|
||||
"stats.wd.1": "一",
|
||||
"stats.wd.2": "二",
|
||||
"stats.wd.3": "三",
|
||||
"stats.wd.4": "四",
|
||||
"stats.wd.5": "五",
|
||||
"stats.wd.6": "六",
|
||||
"stats.wd.7": "日",
|
||||
|
||||
"stats.foot.dataDate": "数据基准日:",
|
||||
"stats.foot.updated": "计算于",
|
||||
"stats.foot.prevMonth": "最后一条提问:",
|
||||
"stats.foot.prevAnswer": "最后一条回答:",
|
||||
"stats.foot.privacy": "所有数字均为聚合结果,不展示单条提问内容、提问者身份或来源地址。",
|
||||
"stats.foot.cacheNote": "累计数字与图表是当日 0 点的快照,每天只更新一次;「今日」两项为实时数字。",
|
||||
}
|
||||
|
||||
@@ -950,4 +950,81 @@ var zhTWDict = map[string]string{
|
||||
"theme.name.sakura": "櫻花",
|
||||
"theme.name.shiroko": "砂狼白子",
|
||||
"theme.name.new-sakura": "新·櫻花",
|
||||
|
||||
// 全站數據頁(/stats):只顯示彙總數字,不含任何單則內容
|
||||
"site.footer.stats": "數據",
|
||||
"stats.title": "全站數據",
|
||||
"stats.lead": "這裡只有彙總後的數字:全站收到多少提問、回答得有多快、大家習慣什麼時候來。不含任何一則具體提問的內容、提問者身分或來源位址。",
|
||||
"stats.unavailable": "數據暫時無法使用,稍後再重新整理看看。",
|
||||
|
||||
"stats.today.questions": "今日提問",
|
||||
"stats.today.answers": "今日回答",
|
||||
"stats.today.tag": "(即時)",
|
||||
|
||||
"stats.kpi.users": "註冊使用者",
|
||||
"stats.kpi.questions": "累計提問",
|
||||
"stats.kpi.answered": "已回答",
|
||||
"stats.kpi.answerRate": "回答率",
|
||||
"stats.kpi.activeBoxes": "收到過提問的箱子",
|
||||
"stats.kpi.messages": "累計私聊訊息",
|
||||
|
||||
"stats.insight.title": "數據速覽",
|
||||
"stats.insight.note": "下面幾句是直接從上面的數字讀出來的,沒有推測。",
|
||||
"stats.insight.empty": "提問數據還太少,暫時看不出什麼。",
|
||||
"stats.insight.daily30": "近 30 天共收到 %s 則提問,日均 %.1f 則。",
|
||||
"stats.insight.peak": "最忙的一天是 %s,收到 %s 則提問。",
|
||||
"stats.insight.busiest": "提問最集中在 %s %d 點前後。",
|
||||
"stats.insight.mom": "上月提問量比前一個月 %s%%。",
|
||||
"stats.insight.fastReply": "已回答的提問中,%s%% 在 24 小時內得到回覆。",
|
||||
"stats.insight.private": "%s%% 的提問選擇了私密(不公開)。",
|
||||
|
||||
"stats.trend.dailyTitle": "近 30 天提問趨勢",
|
||||
"stats.trend.dailyNote": "依天統計的提問量。",
|
||||
"stats.trend.monthlyTitle": "近 12 個月提問量",
|
||||
"stats.trend.monthlyNote": "最後一根柱子是當月,還沒過完。",
|
||||
|
||||
"stats.mix.title": "組成",
|
||||
"stats.mix.note": "三組比例:公開還是私密、有沒有被回答、提問者有沒有登入。",
|
||||
"stats.mix.public": "公開提問",
|
||||
"stats.mix.answered": "已回答",
|
||||
"stats.mix.login": "登入使用者提問",
|
||||
|
||||
"stats.heat.title": "一週內的提問時段",
|
||||
"stats.heat.note": "最近 %d 天的提問依「星期 × 小時」彙總,顏色越深表示越集中。",
|
||||
"stats.heat.less": "少",
|
||||
"stats.heat.more": "多",
|
||||
|
||||
"stats.content.title": "內容與節奏",
|
||||
"stats.content.note": "提問有多長,回答有多快。",
|
||||
"stats.content.lengthTitle": "提問字數分布",
|
||||
"stats.content.replyTitle": "回答耗時分布",
|
||||
|
||||
"stats.len.1": "20 字以內",
|
||||
"stats.len.2": "20–49 字",
|
||||
"stats.len.3": "50–99 字",
|
||||
"stats.len.4": "100–199 字",
|
||||
"stats.len.5": "200–499 字",
|
||||
"stats.len.6": "500 字以上",
|
||||
|
||||
"stats.reply.1": "1 小時內",
|
||||
"stats.reply.2": "1–6 小時",
|
||||
"stats.reply.3": "6–24 小時",
|
||||
"stats.reply.4": "1–3 天",
|
||||
"stats.reply.5": "3–7 天",
|
||||
"stats.reply.6": "7 天以上",
|
||||
|
||||
"stats.wd.1": "一",
|
||||
"stats.wd.2": "二",
|
||||
"stats.wd.3": "三",
|
||||
"stats.wd.4": "四",
|
||||
"stats.wd.5": "五",
|
||||
"stats.wd.6": "六",
|
||||
"stats.wd.7": "日",
|
||||
|
||||
"stats.foot.dataDate": "數據基準日:",
|
||||
"stats.foot.updated": "計算於",
|
||||
"stats.foot.prevMonth": "最後一則提問:",
|
||||
"stats.foot.prevAnswer": "最後一則回答:",
|
||||
"stats.foot.privacy": "所有數字均為彙總結果,不展示單則提問內容、提問者身分或來源位址。",
|
||||
"stats.foot.cacheNote": "累計數字與圖表是當日 0 點的快照,每天只更新一次;「今日」兩項為即時數字。",
|
||||
}
|
||||
|
||||
@@ -231,6 +231,9 @@ func New() *flamego.Flame {
|
||||
f.Get("/about-me", route.AboutMe)
|
||||
// 社区规则:公开页,处置时的引用依据。
|
||||
f.Get("/rules", route.Rules)
|
||||
// 全站数据页:公开只读,纯聚合数字,不含任何单条内容或来源信息。
|
||||
// 数据按自然日缓存(每天算一次),所以这里不需要额外限流。
|
||||
f.Get("/stats", route.Stats)
|
||||
f.Get("/robots.txt", func(c context.Context) {
|
||||
fs, err := static.FS.Open("robots.txt")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
)
|
||||
|
||||
// Overview 是「全站数据」页的展示模型。
|
||||
//
|
||||
// 与 View 的区别:View 是首页那张三行小卡片的口径(30 分钟缓存、只有三个累计数),
|
||||
// Overview 是完整聚合(趋势、构成、热力图、内容分布),按自然日缓存 —— 也就是
|
||||
// 「每天更新一次」:当天第一次访问重算,之后全站共用同一份结果。
|
||||
type Overview struct {
|
||||
// 累计概览
|
||||
RegisteredUsers int64
|
||||
TotalQuestions int64
|
||||
Answered int64
|
||||
Public int64
|
||||
Private int64
|
||||
Read int64
|
||||
Unread int64
|
||||
Anonymous int64
|
||||
LoggedIn int64
|
||||
ActiveBoxes int64
|
||||
AnsweringBoxes int64
|
||||
TotalMessages int64
|
||||
LastQuestionAt string
|
||||
LastAnswerAt string
|
||||
AnswerRate float64 // 已回答 / 总提问,百分比
|
||||
FastReplyRate float64 // 24 小时内获得回答的比例,百分比
|
||||
Total30 int64 // 近 30 天提问总数
|
||||
AvgDailyLast30 float64 // 近 30 天日均提问
|
||||
GrowthMoM float64 // 本月提问量相对上月的环比,百分比(上月为 0 时置 0)
|
||||
HasPrevMonth bool // 上月是否有数据,决定要不要展示环比
|
||||
|
||||
// 今日实时(不参与每日缓存)
|
||||
TodayQuestions int64
|
||||
TodayAnswers int64
|
||||
|
||||
// 趋势(已补齐空日期,值为 0 的日期也占位,避免折线跳过)
|
||||
Daily []DailyPoint
|
||||
Monthly []MonthlyPoint
|
||||
DailyMax int64
|
||||
MonthlyMax int64
|
||||
PeakDay DailyPoint
|
||||
BusiestWD int // 0=周日
|
||||
BusiestHr int // 0-23
|
||||
|
||||
// 热力图
|
||||
Heatmap [7][24]int64
|
||||
HeatmapMax int64
|
||||
HeatmapDays int
|
||||
|
||||
// 内容分布
|
||||
LengthBins [6]int64
|
||||
ReplyBins [6]int64
|
||||
|
||||
// 元信息
|
||||
UpdatedAt string
|
||||
DataDate string
|
||||
}
|
||||
|
||||
// DailyPoint 某一天的提问量。
|
||||
type DailyPoint struct {
|
||||
Date string // 2026-10-01
|
||||
Label string // 10-01
|
||||
N int64
|
||||
}
|
||||
|
||||
// MonthlyPoint 某个月的提问量。
|
||||
type MonthlyPoint struct {
|
||||
Month string // 2026-10
|
||||
Label string // 10
|
||||
N int64
|
||||
Current bool // 是否为当月(页面高亮最后一根柱子)
|
||||
}
|
||||
|
||||
const (
|
||||
dailyWindowDays = 30
|
||||
monthlyWindow = 12
|
||||
)
|
||||
|
||||
var (
|
||||
ovMu sync.Mutex
|
||||
ovCached *Overview
|
||||
ovDay string
|
||||
// ovRefresh 串行化重算:一天的第一个请求承担全部聚合,其余请求等它算完直接复用,
|
||||
// 避免多个并发请求同时打一轮全表 GROUP BY(1C1G 上会互相拖慢)。
|
||||
ovRefresh sync.Mutex
|
||||
)
|
||||
|
||||
// OverviewData 返回全站数据页所需的聚合结果,按自然日缓存。
|
||||
//
|
||||
// 聚合失败时若手上还有昨天的结果,就继续返回旧的(页面照常打开,只是数据停在昨天);
|
||||
// 完全没有可用结果才返回错误。
|
||||
func OverviewData(ctx context.Context) (*Overview, error) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
ovMu.Lock()
|
||||
cached, day := ovCached, ovDay
|
||||
ovMu.Unlock()
|
||||
if cached != nil && day == today {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
ovRefresh.Lock()
|
||||
defer ovRefresh.Unlock()
|
||||
|
||||
// 二次检查:等锁期间可能已有别的请求刷新过。
|
||||
ovMu.Lock()
|
||||
cached, day = ovCached, ovDay
|
||||
ovMu.Unlock()
|
||||
if cached != nil && day == today {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
if db.Analytics == nil {
|
||||
// 单测里没有初始化数据库时不应 panic,交给调用方回退。
|
||||
return nil, errNoDatabase
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
raw, err := db.Analytics.SiteAnalytics(ctx, now)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Warn("计算全站统计数据失败")
|
||||
if cached != nil {
|
||||
return cached, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v := buildOverview(raw, now)
|
||||
|
||||
ovMu.Lock()
|
||||
ovCached = v
|
||||
ovDay = today
|
||||
ovMu.Unlock()
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// InvalidateOverview 丢弃缓存,供需要立即看到新数字的场景调用。
|
||||
func InvalidateOverview() {
|
||||
ovMu.Lock()
|
||||
ovCached = nil
|
||||
ovDay = ""
|
||||
ovMu.Unlock()
|
||||
}
|
||||
|
||||
var errNoDatabase = errString("database not initialized")
|
||||
|
||||
type errString string
|
||||
|
||||
func (e errString) Error() string { return string(e) }
|
||||
|
||||
func buildOverview(raw *db.SiteAnalytics, now time.Time) *Overview {
|
||||
v := &Overview{
|
||||
RegisteredUsers: raw.RegisteredUsers,
|
||||
TotalQuestions: raw.TotalQuestions,
|
||||
Answered: raw.Answered,
|
||||
Public: raw.TotalQuestions - raw.Private,
|
||||
Private: raw.Private,
|
||||
Read: raw.Read,
|
||||
Unread: raw.TotalQuestions - raw.Read,
|
||||
Anonymous: raw.Anonymous,
|
||||
LoggedIn: raw.TotalQuestions - raw.Anonymous,
|
||||
ActiveBoxes: raw.ActiveBoxes,
|
||||
AnsweringBoxes: raw.AnsweringBoxes,
|
||||
TotalMessages: raw.TotalMessages,
|
||||
TodayQuestions: raw.TodayQuestions,
|
||||
TodayAnswers: raw.TodayAnswers,
|
||||
Heatmap: raw.Heatmap,
|
||||
HeatmapDays: raw.HeatmapDays,
|
||||
LengthBins: raw.LengthBins,
|
||||
ReplyBins: raw.ReplyBins,
|
||||
DataDate: now.Format("2006-01-02"),
|
||||
}
|
||||
if v.Public < 0 {
|
||||
v.Public = 0
|
||||
}
|
||||
if v.Unread < 0 {
|
||||
v.Unread = 0
|
||||
}
|
||||
if v.LoggedIn < 0 {
|
||||
v.LoggedIn = 0
|
||||
}
|
||||
if raw.LastQuestionAt != nil {
|
||||
v.LastQuestionAt = raw.LastQuestionAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if raw.LastAnswerAt != nil {
|
||||
v.LastAnswerAt = raw.LastAnswerAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if v.TotalQuestions > 0 {
|
||||
v.AnswerRate = percent(v.Answered, v.TotalQuestions)
|
||||
}
|
||||
if answered := v.Answered; answered > 0 {
|
||||
v.FastReplyRate = percent(v.ReplyBins[0]+v.ReplyBins[1]+v.ReplyBins[2], answered)
|
||||
}
|
||||
|
||||
v.Daily = buildDaily(raw.Daily, now, dailyWindowDays)
|
||||
v.Monthly = buildMonthly(raw.Monthly, now, monthlyWindow)
|
||||
for _, p := range v.Daily {
|
||||
if p.N > v.DailyMax {
|
||||
v.DailyMax = p.N
|
||||
}
|
||||
if p.N >= v.PeakDay.N {
|
||||
v.PeakDay = p
|
||||
}
|
||||
}
|
||||
var dailySum int64
|
||||
for _, p := range v.Daily {
|
||||
dailySum += p.N
|
||||
}
|
||||
v.Total30 = dailySum
|
||||
v.AvgDailyLast30 = float64(dailySum) / float64(dailyWindowDays)
|
||||
|
||||
for _, p := range v.Monthly {
|
||||
if p.N > v.MonthlyMax {
|
||||
v.MonthlyMax = p.N
|
||||
}
|
||||
}
|
||||
// 环比取「上月 vs 上上月」而不是「本月 vs 上月」:本月通常还没过完,
|
||||
// 拿一个残月去比完整月份,每月 1 号都会得出 -95% 这种毫无意义的结论。
|
||||
if n := len(v.Monthly); n >= 3 && v.Monthly[n-3].N > 0 {
|
||||
prev := float64(v.Monthly[n-3].N)
|
||||
v.HasPrevMonth = true
|
||||
v.GrowthMoM = (float64(v.Monthly[n-2].N) - prev) / prev * 100
|
||||
}
|
||||
|
||||
for _, row := range v.Heatmap {
|
||||
for _, n := range row {
|
||||
if n > v.HeatmapMax {
|
||||
v.HeatmapMax = n
|
||||
}
|
||||
}
|
||||
}
|
||||
v.BusiestWD, v.BusiestHr = busiestSlot(v.Heatmap)
|
||||
|
||||
v.UpdatedAt = now.Format("2006-01-02 15:04:05")
|
||||
return v
|
||||
}
|
||||
|
||||
// buildDaily 把「只有有数据的那几天」补齐成连续 N 天,折线才不会在无数据的日期断开。
|
||||
func buildDaily(raw []db.CountPoint, now time.Time, days int) []DailyPoint {
|
||||
index := make(map[string]int64, len(raw))
|
||||
for _, p := range raw {
|
||||
index[p.Key] = p.N
|
||||
}
|
||||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).
|
||||
AddDate(0, 0, -(days - 1))
|
||||
out := make([]DailyPoint, 0, days)
|
||||
for i := 0; i < days; i++ {
|
||||
d := start.AddDate(0, 0, i)
|
||||
key := d.Format("2006-01-02")
|
||||
out = append(out, DailyPoint{Date: key, Label: d.Format("01-02"), N: index[key]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildMonthly(raw []db.CountPoint, now time.Time, months int) []MonthlyPoint {
|
||||
index := make(map[string]int64, len(raw))
|
||||
for _, p := range raw {
|
||||
index[p.Key] = p.N
|
||||
}
|
||||
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).
|
||||
AddDate(0, -(months - 1), 0)
|
||||
out := make([]MonthlyPoint, 0, months)
|
||||
for i := 0; i < months; i++ {
|
||||
m := start.AddDate(0, i, 0)
|
||||
key := m.Format("2006-01")
|
||||
out = append(out, MonthlyPoint{Month: key, Label: m.Format("01"), N: index[key], Current: i == months-1})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// busiestSlot 找出提问最集中的「星期 × 小时」。同值时取更早的下标,结果稳定可复现。
|
||||
func busiestSlot(heatmap [7][24]int64) (weekday, hour int) {
|
||||
best := int64(-1)
|
||||
for d := 0; d < 7; d++ {
|
||||
for h := 0; h < 24; h++ {
|
||||
if heatmap[d][h] > best {
|
||||
best = heatmap[d][h]
|
||||
weekday, hour = d, h
|
||||
}
|
||||
}
|
||||
}
|
||||
return weekday, hour
|
||||
}
|
||||
|
||||
func percent(part, total int64) float64 {
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(part) / float64(total) * 100
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// 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 template
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 图表辅助函数。
|
||||
//
|
||||
// 全站数据页刻意不引入任何前端图表库:本站历史上为消除一个 1.6KB 的外部 defer
|
||||
// 脚本就把页脚计时器内联了,再挂一个 60KB+ 的 CDN 依赖并不划算。
|
||||
// 需要坐标的只有折线(SVG),柱状 / 环形 / 热力图都能用 CSS 直接画。
|
||||
// 因此这里只把「折线要的坐标」算出来,其余交给模板里的百分比与 grid。
|
||||
|
||||
// NumInt 把整数格式化成带千分位分隔的字符串,如 56321 → "56,321"。
|
||||
func NumInt(v int64) string {
|
||||
sign := ""
|
||||
if v < 0 {
|
||||
sign = "-"
|
||||
v = -v
|
||||
}
|
||||
s := strconv.FormatInt(v, 10)
|
||||
if len(s) <= 3 {
|
||||
return sign + s
|
||||
}
|
||||
var b strings.Builder
|
||||
lead := len(s) % 3
|
||||
if lead > 0 {
|
||||
b.WriteString(s[:lead])
|
||||
}
|
||||
for i := lead; i < len(s); i += 3 {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteString(s[i : i+3])
|
||||
}
|
||||
return sign + b.String()
|
||||
}
|
||||
|
||||
// PctText 返回 part 占 total 的百分比,保留一位小数(不含 % 号,便于模板拼接)。
|
||||
func PctText(part, total int64) string {
|
||||
if total <= 0 {
|
||||
return "0.0"
|
||||
}
|
||||
return strconv.FormatFloat(float64(part)/float64(total)*100, 'f', 1, 64)
|
||||
}
|
||||
|
||||
// ratio 把数值序列归一化到 [0,1],空序列或全零时返回全 0(不会出现除零)。
|
||||
func ratioOf(values []int64) ([]float64, float64) {
|
||||
maxV := int64(0)
|
||||
for _, v := range values {
|
||||
if v > maxV {
|
||||
maxV = v
|
||||
}
|
||||
}
|
||||
out := make([]float64, len(values))
|
||||
if maxV == 0 {
|
||||
return out, 0
|
||||
}
|
||||
for i, v := range values {
|
||||
out[i] = float64(v) / float64(maxV)
|
||||
}
|
||||
return out, float64(maxV)
|
||||
}
|
||||
|
||||
// Polyline 生成折线图的 points 属性值。
|
||||
//
|
||||
// w/h 是 viewBox 尺寸,padTop/padBottom 给顶部留呼吸空间、底部留给轴标签。
|
||||
// 返回 "x1,y1 x2,y2 ...";空数据返回空串(模板里 SVG 折线自然消失)。
|
||||
func Polyline(values []int64, w, h, padTop, padBottom float64) string {
|
||||
n := len(values)
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
ratios, _ := ratioOf(values)
|
||||
usable := h - padTop - padBottom
|
||||
if usable < 1 {
|
||||
usable = 1
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, r := range ratios {
|
||||
var x float64
|
||||
if n == 1 {
|
||||
x = w / 2
|
||||
} else {
|
||||
x = float64(i) * w / float64(n-1)
|
||||
}
|
||||
y := padTop + usable - r*usable
|
||||
if i > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
fmt.Fprintf(&b, "%.1f,%.1f", x, y)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// AreaPath 把同一组数值渲染成闭合的面积路径,用于折线下方的浅色填充。
|
||||
func AreaPath(values []int64, w, h, padTop, padBottom float64) string {
|
||||
points := Polyline(values, w, h, padTop, padBottom)
|
||||
if points == "" {
|
||||
return ""
|
||||
}
|
||||
return "M0," + strconv.FormatFloat(h, 'f', 1, 64) + " L" +
|
||||
strings.ReplaceAll(points, " ", " L") +
|
||||
" L" + strconv.FormatFloat(w, 'f', 1, 64) + "," + strconv.FormatFloat(h, 'f', 1, 64) + " Z"
|
||||
}
|
||||
|
||||
// HeatLevel 把热力图的一个格子映射到 0-4 的强度档位(0 表示没有提问)。
|
||||
// 用档位而不是连续透明度:连续值在小方块上肉眼分不出来,反而让人怀疑渲染出错。
|
||||
func HeatLevel(n, max int64) int {
|
||||
if n <= 0 || max <= 0 {
|
||||
return 0
|
||||
}
|
||||
switch {
|
||||
case n*4 <= max:
|
||||
return 1
|
||||
case n*2 <= max:
|
||||
return 2
|
||||
case n*4 <= max*3:
|
||||
return 3
|
||||
default:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,14 @@ func FuncMap() []template.FuncMap {
|
||||
"moderationStrictness": func(raw string) string {
|
||||
return string(moderation.StrictnessFor(raw))
|
||||
},
|
||||
|
||||
// 全站数据页的图表辅助:数字格式化 + 折线坐标(见 chart.go)。
|
||||
// 站点不引前端图表库,柱状 / 环形 / 热力图都用 CSS 画,只有折线需要坐标。
|
||||
"NumInt": NumInt,
|
||||
"PctText": PctText,
|
||||
"Polyline": Polyline,
|
||||
"AreaPath": AreaPath,
|
||||
"HeatLevel": HeatLevel,
|
||||
}}
|
||||
})
|
||||
return funcMap
|
||||
|
||||
Reference in New Issue
Block a user