diff --git a/.workbuddy/memory/2026-10-23.md b/.workbuddy/memory/2026-10-23.md index af2576a..e387975 100644 --- a/.workbuddy/memory/2026-10-23.md +++ b/.workbuddy/memory/2026-10-23.md @@ -221,3 +221,30 @@ Tomcat 的 skin 忘了 css(按钮白字无底色)、WP 登录页底部挂着 - 量手机宽度别用 `chrome --window-size=360`:Windows headless=old 有 ~500px 最小窗宽, 实测 innerWidth 恒为 500。用 360px 宽 iframe + `--allow-file-access-from-files`, 结果由 iframe 里 `window.top.document.title` 带回,`--dump-dom` 读。 + +### 手机端时间的年份:逐条补年,不做按年分组(DateCompact) + +- `Template.FuncMap` 新增 `DateCompact`(内部 `dateCompact(t, now)`):同年 → `01-02 15:04`, + 跨年 → `2006-01-02 15:04`。桌面端仍走 `tb-text` 的完整格式,不受影响。 +- 用户问过「省了年要不要按年把问题隔开」——**不做**。理由是列表带游标分页(`*_cursor`)+ 可切 + 排序 + 4 个筛选 + 关键词搜索:组标题只按当前页算,翻页会重复出现同一年标题;换了排序 + (最早→最新)或搜索时又会出现空组/错位。逐条补年份零结构成本、无耦合。 + 真要年份维度,顺序应是:①跨年补年(已做)②筛选栏加「年份」下拉(distinct year,走 SQL)。 +- 实测(360 宽 306px):跨年格式占 109px(vs 同年 73px)。跨年 + 2 标签 + 2 按钮 = 309px, + 差 3px 会在 360 宽折行;390 及以上的 2 标签组合都单行。预览数据里放了一条 2025 年条目用于核对。 + +## 全站数据页 /stats(替换首页统计卡) + +- 首页底部「站点统计」三行小卡 → 换成一行摘要 + 入口按钮;完整数据在新公开页 `/stats`。 + 明细见 `topics/stats.md`。 +- 新增:`internal/db/analytics.go`(聚合 SQL,方言收敛)、`internal/stats/overview.go` + (自然日缓存 + 补齐空日期)、`internal/stats/page.go`(i18n 标签 + 洞察文案)、 + `route/stats.go`、`templates/stats.html`、`internal/template/chart.go`(折线坐标等 5 个函数)。 +- 图表零依赖:折线 SVG,柱状/分布条用百分比高度,环形用 `pathLength=100` 的 dasharray, + 热力图 CSS grid + 主色 opacity 分 4 档。 +- 口径修正:环比改成「上月 vs 上上月」(本月残月比整月会在每月 1 号算出 -95%)。 +- 补索引 `questions(created_at)`;启动后 3s 异步预热当天快照。 +- 测试:`internal/db/analytics_test.go` 连真库验证(软删除不计入、分桶下标、DOW 对齐)、 + `internal/stats/overview_test.go`(补齐日期、月底不溢出、除零)。全绿。 +- 踩坑:预览页 base.css 用 `href="/static/..."` 绝对路径,file:// 下取不到 → + 令牌全缺、图表全透明。`writePreview` 已改成同时重写 `href="/static/`。 diff --git a/.workbuddy/memory/2026-10-24.md b/.workbuddy/memory/2026-10-24.md new file mode 100644 index 0000000..f2dfc53 --- /dev/null +++ b/.workbuddy/memory/2026-10-24.md @@ -0,0 +1,15 @@ +# 2026-10-24 + +## /stats 入口收敛到页脚 + +- 用户要求:首页那张统计入口卡取消,入口只放页脚「社区规则」与「找站长」之间。 +- 改动: + - `templates/home.html`:删掉统计入口卡整块 + `.tb-stats-entry*` 样式(原 `` 后那一段)。 + - `route/home.go`:`Home` 不再调 `stats.Get`(模板已不读 `.SiteStats`),去掉 `logrus` import; + `/api/v1/site-stats` 与 `SiteStatsAPI` 保留未动。 + - 四份 i18n 字典删掉已无引用的 `site.stats.open`(`site.stats` 老词条保留)。 + - 页脚 `templates/base/footer.html` 的 `site.footer.stats` 位置本就在 rules 与 admin 之间,无需再动。 +- 验证:`go build` + `./internal/i18n` / `./templates` / `./route/...` 测试全绿;gofmt 无差异; + 重新生成预览确认首页无 `tb-stats-entry`,页脚渲染为「社区规则 | 数据 | 找站长」。 +- 教训:删掉页面唯一消费方后,服务端的取数调用(`stats.Get`)要同步删, + 否则每次访问首页白算一次快照;对应 i18n 词条也要一起清,避免留死条目。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 5039bd3..e930a09 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -3,7 +3,7 @@ 明细已拆分到 `topics/*.md`(按需 Read 全文): `coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 / `gorm` GORM / `style` 前端样式 / `theme` 主题 / `builder` 主题生成器 / `share` 分享卡 / -`honeypot` 蜜罐多家族伪装 / `misc` 其它约定。 +`honeypot` 蜜罐多家族伪装 / `stats` 全站数据页 / `misc` 其它约定。 ## 协作 diff --git a/.workbuddy/memory/topics/stats.md b/.workbuddy/memory/topics/stats.md new file mode 100644 index 0000000..ffd4040 --- /dev/null +++ b/.workbuddy/memory/topics/stats.md @@ -0,0 +1,66 @@ +# 全站数据页(/stats) + +2026-10 新增。完整数据在 `/stats`(公开、只读、纯服务端渲染)。 + +入口只有一处:**页脚「社区规则 | 数据 | 找站长」中间那格** +(`templates/base/footer.html`,词条 `site.footer.stats`)。 +首页那张「站点统计」卡与后来的入口卡都已下线(用户明确要求首页不再放), +`route.Home` 也不再调 `stats.Get`;`/api/v1/site-stats` 接口保留未动。 +新增入口不要再往首页加——那会让两处数字需要同步维护。 + +## 分层 + +``` +db.Analytics.SiteAnalytics(ctx, now) // 纯 SQL 聚合,无缓存 + → stats.OverviewData(ctx) // 按自然日缓存(每天只算一次) + → stats.NewPage(lang, ov) // i18n 标签 + 百分比 + 文字洞察 + → route.Stats → templates/stats.html +``` + +- `internal/db/analytics.go`:`SiteAnalyticsStore`。用 `SUM(CASE WHEN)` 而不是 + `COUNT(*) FILTER`(MySQL 没有 FILTER)。方言差异(`to_char`/`DATE_FORMAT`、 + `EXTRACT(DOW)`/`DAYOFWEEK-1`、`EPOCH`/`TIMESTAMPDIFF`)收敛在 `analyticsDialectFor`。 +- `internal/stats/overview.go`:缓存 key 是**自然日字符串**,不是 TTL —— 换日即失效, + 当天重算一次后全站共用。重算串行化(`ovRefresh`),失败时返回上一次成功的旧值。 +- `internal/stats/page.go`:`Page` 嵌 `*Overview`。桶标签在 Go 侧拼好,别在模板里 + `printf "stats.len.%s"` —— 缺词条会把 key 印到页面上。 +- `internal/cmd/web.go`:启动后 3s 异步预热,把当天首访的等待挪出请求路径。 + +## 口径(改之前先看) + +- 已回答 = `answer <> ''`;已读 = `is_read OR answer <> ''`。 +- 匿名 = `asker_user_id = 0`;登录 = 总数 − 匿名。 +- 回答耗时用 `updated_at - created_at`,但**置顶等写操作也会刷新 updated_at**, + 所以只对外给「24 小时内占比」这种粗结论,不宣称中位数。 +- 环比是「上月 vs 上上月」,不是「本月 vs 上月」:本月没过完,每月 1 号会得出 -95%。 +- 累计与图表是当日 0 点快照,「今日提问/回答」两项实时。页面底部已写明。 + +## 图表实现(零前端库) + +站点为消除一个 1.6KB 外部 defer 就把页脚计时器内联了,这里不再挂 60KB 图表库。 + +- 折线:`Polyline` / `AreaPath` 模板函数算坐标,SVG `viewBox` + `preserveAspectRatio="none"` + + `vector-effect: non-scaling-stroke`(否则窄屏描边被压细)。 +- 柱状 / 分布条:`style="height:{{printf "%.1f" $pct}}%"`,CSS 直接用百分比。 +- 环形:`` + `rotate(-90)`, + 不用算周长。 +- 热力图:CSS grid 24 列 + `aspect-ratio:1`,档位用 `--tb-primary` 的 opacity 分 4 档 + (连续透明度在小方块上肉眼分不出来)。 +- 7×24 的 DOW 下标:PG `EXTRACT(DOW)` 与 Go `time.Weekday()` 都是周日=0; + 展示顺序(周一起)在 `weekdayOrder` 里转。 + +## 预览与验证 + +- `TEST_PREVIEW=1 go test ./templates -run TestGenPreview` 出 + `outputs/preview-stats-{zh-CN,en}-{light,dark}.html`。 +- `writePreview` 会把 `href="/static/` 换成本地相对路径 —— 不换的话预览页走 file://, + `:root` 令牌全缺,`var(--tb-*)` 变无效值:图表全透明、卡片没边框(一度以为是渲染 bug)。 +- `internal/db/analytics_test.go` 连真库跑(`DB_TYPE=postgres ...`),验证软删除不计入、 + 各分桶下标、DOW 对齐。对着 mock 断言等于没验证。 +- 窄屏别用 `chrome --window-size=390`:Windows headless 最小窗宽约 485,实测 innerWidth=485, + 截图再裁到 390 就成了假的"溢出"。用 390px 宽 iframe 包一层再截。 + +## 隐私红线 + +公开页只给聚合数字。不显示 IP、不显示单条内容、不做箱主排行(会招骚扰)。 +「收到过提问的箱子」这类计数可以,具体是谁不行。 diff --git a/internal/cmd/web.go b/internal/cmd/web.go index 09bf774..c7e8a4f 100644 --- a/internal/cmd/web.go +++ b/internal/cmd/web.go @@ -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 防止读写挂死 diff --git a/internal/db/analytics.go b/internal/db/analytics.go new file mode 100644 index 0000000..2b51932 --- /dev/null +++ b/internal/db/analytics.go @@ -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 +} diff --git a/internal/db/analytics_test.go b/internal/db/analytics_test.go new file mode 100644 index 0000000..d403022 --- /dev/null +++ b/internal/db/analytics_test.go @@ -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: "boxa@example.com"}).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 +} diff --git a/internal/db/db.go b/internal/db/db.go index b42297e..9a877a4 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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,帮不上这个排序。 diff --git a/internal/i18n/dict_en.go b/internal/i18n/dict_en.go index cac1ba8..4905e13 100644 --- a/internal/i18n/dict_en.go +++ b/internal/i18n/dict_en.go @@ -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.", } diff --git a/internal/i18n/dict_ja.go b/internal/i18n/dict_ja.go index c58dee9..a99433e 100644 --- a/internal/i18n/dict_ja.go +++ b/internal/i18n/dict_ja.go @@ -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 項目はリアルタイムです。", } diff --git a/internal/i18n/dict_zh_cn.go b/internal/i18n/dict_zh_cn.go index 6c63340..2325b26 100644 --- a/internal/i18n/dict_zh_cn.go +++ b/internal/i18n/dict_zh_cn.go @@ -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 点的快照,每天只更新一次;「今日」两项为实时数字。", } diff --git a/internal/i18n/dict_zh_tw.go b/internal/i18n/dict_zh_tw.go index 4e9ea7f..cef9a9e 100644 --- a/internal/i18n/dict_zh_tw.go +++ b/internal/i18n/dict_zh_tw.go @@ -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 點的快照,每天只更新一次;「今日」兩項為即時數字。", } diff --git a/internal/route/route.go b/internal/route/route.go index eb026de..fd51c7a 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -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 { diff --git a/internal/stats/overview.go b/internal/stats/overview.go new file mode 100644 index 0000000..5142026 --- /dev/null +++ b/internal/stats/overview.go @@ -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 +} diff --git a/internal/stats/overview_test.go b/internal/stats/overview_test.go new file mode 100644 index 0000000..ffbc74b --- /dev/null +++ b/internal/stats/overview_test.go @@ -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) +} diff --git a/internal/stats/page.go b/internal/stats/page.go new file mode 100644 index 0000000..453129b --- /dev/null +++ b/internal/stats/page.go @@ -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) +} diff --git a/internal/template/chart.go b/internal/template/chart.go new file mode 100644 index 0000000..eb2411c --- /dev/null +++ b/internal/template/chart.go @@ -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 + } +} diff --git a/internal/template/template.go b/internal/template/template.go index 71c0f30..1446420 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -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 diff --git a/route/home.go b/route/home.go index 934b4c5..dbf6939 100644 --- a/route/home.go +++ b/route/home.go @@ -5,19 +5,16 @@ package route import ( - "github.com/sirupsen/logrus" - "github.com/tamakyi/TamaBox/internal/context" "github.com/tamakyi/TamaBox/internal/i18n" "github.com/tamakyi/TamaBox/internal/stats" ) +// Home 首页。 +// +// 首页不再展示站点统计(原「提问箱信息统计」卡片已下线,完整数据在 /stats), +// 因此不必在每次访问首页时计算一次统计快照。 func Home(ctx context.Context) { - if v, err := stats.Get(ctx.Request().Context()); err != nil { - logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to compute site stats") - } else { - ctx.Data["SiteStats"] = v - } ctx.Success("home") } diff --git a/route/stats.go b/route/stats.go new file mode 100644 index 0000000..92ff5a0 --- /dev/null +++ b/route/stats.go @@ -0,0 +1,35 @@ +// 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 route + +import ( + "github.com/sirupsen/logrus" + + "github.com/tamakyi/TamaBox/internal/context" + "github.com/tamakyi/TamaBox/internal/i18n" + "github.com/tamakyi/TamaBox/internal/stats" +) + +// Stats 全站公开数据页。 +// +// 数据每天只算一次(stats.OverviewData 按自然日缓存),页面是纯服务端渲染: +// 折线用 SVG、柱状/环形/热力图用 CSS,不挂任何前端图表库,也不需要 JS。 +// 这样即使访客禁用脚本、或外部 CDN 抽风,数字和图表都还在。 +func Stats(ctx context.Context) { + lang := ctx.Lang() + ctx.SetTitle(i18n.T(lang, "stats.title")) + + ov, err := stats.OverviewData(ctx.Request().Context()) + if err != nil { + // 数据页不该因为一次聚合失败就 500:模板在 Overview 缺失时显示「数据暂不可用」, + // 标题、说明、导航照常可用。 + logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("加载全站统计数据失败") + ctx.Success("stats") + return + } + + ctx.Data["Overview"] = stats.NewPage(lang, ov) + ctx.Success("stats") +} diff --git a/templates/base/footer.html b/templates/base/footer.html index 5df1e28..efa894d 100644 --- a/templates/base/footer.html +++ b/templates/base/footer.html @@ -1,6 +1,7 @@


{{t .LangCode "site.footer.rules"}} | + {{t .LangCode "site.footer.stats"}} | {{t .LangCode "site.footer.admin"}}
diff --git a/templates/embed.go b/templates/embed.go index cf411eb..5446445 100644 --- a/templates/embed.go +++ b/templates/embed.go @@ -8,5 +8,5 @@ import ( "embed" ) -//go:embed auth base mail question user chat vote home.html about-me.html sponsor.html rules.html change-logs.html pixel.html maintenance-mode.html +//go:embed auth base mail question user chat vote home.html about-me.html sponsor.html rules.html change-logs.html pixel.html maintenance-mode.html stats.html var FS embed.FS diff --git a/templates/home.html b/templates/home.html index 4670923..55c19dd 100644 --- a/templates/home.html +++ b/templates/home.html @@ -100,36 +100,9 @@ transition: transform 0.2s ease; } .tb-changelog[open] summary::before { transform: rotate(90deg); } - .tb-stats { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; - text-align: center; - } - .tb-stat-num { - display: block; - font-size: var(--tb-fs-xl); - font-weight: 600; - line-height: 1.3; - color: var(--tb-text); - } - .tb-stat-label { - display: block; - margin-top: 2px; - font-size: var(--tb-fs-xs); - color: var(--tb-text-muted); - } - .tb-stats-meta { - margin: 14px 0 0; - font-size: var(--tb-fs-xs); - line-height: 1.8; - color: var(--tb-text-muted); - text-align: center; - } @media (max-width: 480px) { .tb-hero { padding: 28px 16px 24px; } .tb-hero-title { font-size: var(--tb-fs-xl); } - .tb-stat-num { font-size: var(--tb-fs-lg); } } @@ -196,50 +169,6 @@ -

-

{{t .LangCode "site.stats"}}

-
-
- {{if .SiteStats}}{{.SiteStats.RegisteredUsers}}{{else}}-{{end}} - 注册用户 -
-
- {{if .SiteStats}}{{.SiteStats.TotalQuestions}}{{else}}-{{end}} - 累计提问 -
-
- {{if .SiteStats}}{{.SiteStats.AnsweredQuestions}}{{else}}-{{end}} - 已获回答 -
-
-

- 最后提问:{{if .SiteStats}}{{.SiteStats.LastQuestionAt}}{{else}}-{{end}}
- 最后回答:{{if .SiteStats}}{{.SiteStats.LastAnswerAt}}{{else}}-{{end}}
- 数据更新于:{{if .SiteStats}}{{.SiteStats.UpdatedAt}}{{else}}-{{end}} -

-
-