diff --git a/internal/cmd/web.go b/internal/cmd/web.go index d7cf3fa..a3d6dd0 100644 --- a/internal/cmd/web.go +++ b/internal/cmd/web.go @@ -21,6 +21,7 @@ import ( "github.com/tamakyi/TamaBox/internal/conf" "github.com/tamakyi/TamaBox/internal/db" + "github.com/tamakyi/TamaBox/internal/insights" "github.com/tamakyi/TamaBox/internal/route" "github.com/tamakyi/TamaBox/internal/security/honeypot" "github.com/tamakyi/TamaBox/internal/shared" @@ -122,6 +123,16 @@ func runWeb(ctx *cli.Context) error { logrus.WithContext(ctx.Context).WithField("external_url", conf.App.ExternalURL).Info("Starting web server") + // 箱主数据面板的访问记录器:异步批量写 box_views,不阻塞页面渲染。 + // 密钥复用 server.xsrf_key —— 它已经是启动期强校验过的非空随机串, + // 而且与 CSRF token 的用途不同(这里只做 HMAC 的一次性密钥),不存在混用风险。 + insightsStore := db.BoxViews + if insightsStore != nil { + insights.Views = insights.NewRecorder(insightsStore, conf.Server.XSRFKey) + defer insights.Views.Close() + insights.StartMaintenance(ctx.Context, insightsStore) + } + // 站点默认语言初始化(default_lang 配置,未配置时回退 zh-CN) shared.App.SetLang(conf.App.DefaultLang) diff --git a/internal/context/context.go b/internal/context/context.go index 676170a..354c327 100644 --- a/internal/context/context.go +++ b/internal/context/context.go @@ -378,9 +378,10 @@ func pageScope(path string) string { path == "/register" || strings.HasPrefix(path, "/register/"): return "auth" case path == "/user" || strings.HasPrefix(path, "/user/"): - // 题目/私聊管理列表是「翻找内容」的页面,和「设置表单」的信息密度差很多, + // 题目/私聊管理列表、数据面板都是「读数据」的页面,和「设置表单」的信息密度差很多, // 装饰要分开配:admin 更该退到背景,profile 可以保留一点氛围。 - if strings.HasPrefix(path, "/user/questions") || strings.HasPrefix(path, "/user/votes") { + if strings.HasPrefix(path, "/user/questions") || strings.HasPrefix(path, "/user/votes") || + strings.HasPrefix(path, "/user/insights") { return "admin" } return "profile" diff --git a/internal/context/context_test.go b/internal/context/context_test.go index c4d3e71..b582eb0 100644 --- a/internal/context/context_test.go +++ b/internal/context/context_test.go @@ -42,6 +42,7 @@ func TestPageScope(t *testing.T) { "/user/profile": "profile", "/user/questions": "admin", // 题目列表:信息密,装饰要退 "/user/votes/1/results": "admin", + "/user/insights": "admin", // 数据面板同属「读数据」 "/_/admin6": "box", "/_/admin6/": "box", "/_/admin6/tag/杂谈": "box", diff --git a/internal/db/box_analytics.go b/internal/db/box_analytics.go new file mode 100644 index 0000000..5767197 --- /dev/null +++ b/internal/db/box_analytics.go @@ -0,0 +1,221 @@ +// Copyright 2026 tamakyi. 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" +) + +// BoxAnalyticsStore 是「箱主数据面板」里提问侧的聚合查询(访问侧见 BoxViewsStore)。 +// +// 与 SiteAnalyticsStore 分开:那个是站方看的全站数据(不含任何单箱维度), +// 这个是箱主看自己的。两边的 SQL 都按 user_id 走,混在一个接口里会让 +// 「全站聚合整天缓存」和「箱主实时看自己的」两种缓存策略纠缠在一起。 +type BoxAnalyticsStore interface { + // BoxTrend 返回 [from, to] 内每天的提问数与回答数。只返回有数据的天,缺日由上层补零。 + BoxTrend(ctx context.Context, userID uint, from, to time.Time) ([]BoxDayCount, error) + // BoxHours 返回 since 之后该箱收到提问的 24 小时分布(下标 0..23)。 + BoxHours(ctx context.Context, userID uint, since time.Time) ([24]int64, error) + // BoxFunnel 返回该箱的提问处理漏斗。 + BoxFunnel(ctx context.Context, userID uint) (*BoxFunnel, error) +} + +// BoxDayCount 某一天的提问 / 回答计数。 +type BoxDayCount struct { + Day time.Time `gorm:"column:day"` + Questions int64 `gorm:"column:questions"` + Answers int64 `gorm:"column:answers"` +} + +// BoxFunnel 箱主的提问处理漏斗与响应速度。 +type BoxFunnel struct { + Total int64 // 全部提问 + Answered int64 // 已回答 + ReadOnly int64 // 已读未答 + Unread int64 // 未读未答 + Private int64 // 私密提问 + Anonymous int64 // 匿名(未登录)提问 + Pinned int64 // 置顶 + // 响应速度只统计已回答且 answered_at 有值的行(历史行退回 updated_at, + // 会有偏高,见 answered_at 的注释)。 + RepliedIn24h int64 + AvgReplyHour float64 + // LastQuestionAt / LastAnswerAt 用于「你多久没看提问箱了」这类提示。 + LastQuestionAt *time.Time + LastAnswerAt *time.Time +} + +var BoxAnalytics BoxAnalyticsStore + +var _ BoxAnalyticsStore = (*boxAnalytics)(nil) + +func NewBoxAnalyticsStore(db *gorm.DB) BoxAnalyticsStore { + return &boxAnalytics{db} +} + +type boxAnalytics struct { + *gorm.DB +} + +func (db *boxAnalytics) BoxTrend(ctx context.Context, userID uint, from, to time.Time) ([]BoxDayCount, error) { + d := analyticsDialectFor(db.DB, from) + from, to = dayOnly(from), dayOnly(to).AddDate(0, 0, 1) + + // 提问与回答分两条 SQL 再在内存里合并:一条 SQL 里同时按 created_at 和 + // answered_at 分组要写成两条子查询再 FULL JOIN,MySQL 没有 FULL JOIN, + // 而两张结果集都很小,合并成本可以忽略。 + type row struct { + Key string + N int64 + } + + var qs, as []row + if err := db.WithContext(ctx).Model(&Question{}). + Select(d.day+" AS key, COUNT(*) AS n"). + Where("user_id = ? AND created_at >= ? AND created_at < ?", userID, from, to). + Group(d.day). + Scan(&qs).Error; err != nil { + return nil, errors.Wrap(err, "aggregate box questions by day") + } + if err := db.WithContext(ctx).Model(&Question{}). + Select(d.day+" AS key, COUNT(*) AS n"). + Where("user_id = ? AND answer <> '' AND COALESCE(answered_at, updated_at) >= ? AND COALESCE(answered_at, updated_at) < ?", + userID, from, to). + Group(d.day). + Scan(&as).Error; err != nil { + return nil, errors.Wrap(err, "aggregate box answers by day") + } + + // 用 map 合并,最后仍交给上层按日期补零(上层最清楚要展示哪些天)。 + byDay := make(map[string]*BoxDayCount, len(qs)) + order := make([]string, 0, len(qs)+len(as)) + get := func(key string) *BoxDayCount { + if v, ok := byDay[key]; ok { + return v + } + t, err := time.ParseInLocation("2006-01-02", key, from.Location()) + if err != nil { + t = from + } + v := &BoxDayCount{Day: t} + byDay[key] = v + order = append(order, key) + return v + } + for _, r := range qs { + get(r.Key).Questions = r.N + } + for _, r := range as { + get(r.Key).Answers = r.N + } + + out := make([]BoxDayCount, 0, len(order)) + for _, key := range order { + out = append(out, *byDay[key]) + } + return out, nil +} + +func (db *boxAnalytics) BoxHours(ctx context.Context, userID uint, since time.Time) ([24]int64, error) { + var out [24]int64 + d := analyticsDialectFor(db.DB, since) + + var rows []struct { + Hr int + N int64 + } + if err := db.WithContext(ctx).Model(&Question{}). + Select(d.hour+" AS hr, COUNT(*) AS n"). + Where("user_id = ? AND created_at >= ?", userID, since). + Group(d.hour). + Scan(&rows).Error; err != nil { + return out, errors.Wrap(err, "aggregate box question hours") + } + for _, r := range rows { + if r.Hr >= 0 && r.Hr < 24 { + out[r.Hr] = r.N + } + } + return out, nil +} + +func (db *boxAnalytics) BoxFunnel(ctx context.Context, userID uint) (*BoxFunnel, error) { + out := &BoxFunnel{} + + var totals struct { + Total int64 `gorm:"column:total"` + Answered int64 `gorm:"column:answered"` + ReadOnly int64 `gorm:"column:read_only"` + Unread int64 `gorm:"column:unread"` + Private int64 `gorm:"column:private"` + Anonymous int64 `gorm:"column:anonymous"` + Pinned int64 `gorm:"column:pinned"` + LastQ *time.Time `gorm:"column:last_q"` + LastA *time.Time `gorm:"column:last_a"` + } + if 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 answer = '' AND is_read THEN 1 ELSE 0 END), 0) AS read_only, + COALESCE(SUM(CASE WHEN answer = '' AND NOT is_read THEN 1 ELSE 0 END), 0) AS unread, + COALESCE(SUM(CASE WHEN is_private THEN 1 ELSE 0 END), 0) AS private, + COALESCE(SUM(CASE WHEN asker_user_id = 0 THEN 1 ELSE 0 END), 0) AS anonymous, + COALESCE(SUM(CASE WHEN is_pinned THEN 1 ELSE 0 END), 0) AS pinned, + MAX(created_at) AS last_q, + MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at) END) AS last_a`). + Where("user_id = ?", userID). + Scan(&totals).Error; err != nil { + return nil, errors.Wrap(err, "aggregate box funnel") + } + out.Total = totals.Total + out.Answered = totals.Answered + out.ReadOnly = totals.ReadOnly + out.Unread = totals.Unread + out.Private = totals.Private + out.Anonymous = totals.Anonymous + out.Pinned = totals.Pinned + out.LastQuestionAt = totals.LastQ + out.LastAnswerAt = totals.LastA + + // 响应速度:answered_at 为空的历史行退回 updated_at(口径偏大,只用于展示大致量级)。 + replyMinutes := boxReplyMinutesExpr(db.DB, out.LastAnswerAt) + if replyMinutes == "" { + return out, nil + } + var speed struct { + In24h int64 `gorm:"column:in_24h"` + Avg *float64 `gorm:"column:avg_min"` + } + if err := db.WithContext(ctx).Model(&Question{}). + Select(fmt.Sprintf(`COALESCE(SUM(CASE WHEN %[1]s <= 1440 THEN 1 ELSE 0 END), 0) AS in_24h, + AVG(%[1]s) AS avg_min`, replyMinutes)). + Where("user_id = ? AND answer <> ''", userID). + Scan(&speed).Error; err != nil { + return nil, errors.Wrap(err, "aggregate box reply speed") + } + out.RepliedIn24h = speed.In24h + if speed.Avg != nil && *speed.Avg > 0 { + out.AvgReplyHour = *speed.Avg / 60 + } + return out, nil +} + +// boxReplyMinutesExpr 返回「回答耗时(分钟)」的 SQL 表达式。 +// +// 与 analyticsDialectFor 里的 replyMin 口径一致,但这里必须能固定到箱主自己那张表: +// 表达式里出现的列名都来自 questions 表,不需要额外限定。 +func boxReplyMinutesExpr(db *gorm.DB, at *time.Time) string { + now := time.Now() + if at != nil { + now = *at + } + d := analyticsDialectFor(db, now) + return d.replyMin +} diff --git a/internal/db/box_views.go b/internal/db/box_views.go new file mode 100644 index 0000000..27aa348 --- /dev/null +++ b/internal/db/box_views.go @@ -0,0 +1,234 @@ +// Copyright 2026 tamakyi. 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" + "time" + + "github.com/pkg/errors" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// BoxViewsStore 记录「提问箱主页被访问了多少次」。 +// +// 为什么要自带一张表:全站数据页(internal/stats)只统计提问/回答/用户, +// 完全没有「有人来看过我的箱」这个维度 —— 箱主看不到自己的任何流量。 +// 曾经用过外部计数服务(busuanzi),但它把统计放在第三方、且只能显示两个数, +// 既不可自控也无法按天看趋势,已移除。 +// +// 口径与隐私: +// - 一行 = 一个访客在某一天访问了某个箱(唯一索引强制),`views` 是当天的次数; +// - PV = SUM(views),UV = 行数(因为每行已按访客去重); +// - 访客标识是 **带日盐的 IP HMAC**,不是 IP 本身,也无法跨天关联同一人。 +// 代价是「近 7 天 UV」= 各日 UV 之和,同一人多天来访会被重复计入 —— 这是 +// 隐私与准确性的取舍,页面上按「按日去重的独立访客」表述。 +type BoxViewsStore interface { + // Record 累加一次访问。实现为单条 UPSERT,并发安全。 + Record(ctx context.Context, opts RecordBoxViewOptions) error + // RecordBatch 批量累加(记录器的 flush 用)。单条 SQL,避免 N 次往返。 + RecordBatch(ctx context.Context, opts []RecordBoxViewOptions) error + // Daily 返回 [from, to] 内每天的 PV / UV,日期按天升序,缺日不补(由上层补零)。 + Daily(ctx context.Context, userID uint, from, to time.Time) ([]BoxViewDay, error) + // TopDays 取历史上 PV 最高的若干天,供「你的最好一天」这类结论使用。 + TopDays(ctx context.Context, userID uint, limit int) ([]BoxViewDay, error) + // FirstDay 返回最早有记录的一天;无记录时返回零值时间。 + FirstDay(ctx context.Context, userID uint) (time.Time, error) + // Totals 返回全期累计访问量(PV / UV)。 + Totals(ctx context.Context, userID uint) (pv, uv int64, err error) + // Purge 删除 before 之前的明细,控制表体积。统计窗口最长 30 天, + // 但「历史最好一天」要看全部,默认保留期由调用方决定(见 insights.Retention)。 + Purge(ctx context.Context, before time.Time) (int64, error) +} + +// BoxView 一次「访客 × 天 × 箱」的访问记录。 +// +// 刻意不内嵌 dbutil.Model:统计流水不需要软删除,而软删除会让唯一索引 +// (user_id, viewed_on, visitor) 失效 —— 被「删掉」的行仍然占着槽位, +// 同一访客当天再也插不进去(GORM 软删除 + 唯一索引的经典坑)。 +type BoxView struct { + ID uint `gorm:"primarykey"` + UserID uint `gorm:"uniqueIndex:idx_box_views_day_visitor,priority:1;index:idx_box_views_user_day,priority:1"` + ViewedOn time.Time `gorm:"type:date;uniqueIndex:idx_box_views_day_visitor,priority:2;index:idx_box_views_user_day,priority:2"` + Visitor string `gorm:"size:32;uniqueIndex:idx_box_views_day_visitor,priority:3"` + Views int64 `gorm:"not null;default:1"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// BoxViewDay 某一天聚合后的访问量。 +type BoxViewDay struct { + // Day 当天 0 点(调用方传入的时区)。扫描出来是 date 列,loc 由数据库会话决定, + // 上层只取 Year/Month/Day 拼 key,不依赖时分秒。 + Day time.Time `gorm:"column:viewed_on"` + PV int64 `gorm:"column:pv"` + UV int64 `gorm:"column:uv"` +} + +type RecordBoxViewOptions struct { + UserID uint + ViewedOn time.Time // 只有年月日有意义,写入前会被截断到当天 0 点 + Visitor string +} + +var BoxViews BoxViewsStore + +var _ BoxViewsStore = (*boxViews)(nil) + +func NewBoxViewsStore(db *gorm.DB) BoxViewsStore { + return &boxViews{db} +} + +type boxViews struct { + *gorm.DB +} + +// dayOnly 把时间截断到当天 0 点。用传入时间的时区,不做 UTC 归一 —— +// 站点按本地时间切「今天」,跨时区归一会让日报表偏一天。 +func dayOnly(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} + +func (db *boxViews) Record(ctx context.Context, opts RecordBoxViewOptions) error { + return db.RecordBatch(ctx, []RecordBoxViewOptions{opts}) +} + +// RecordBatch 单条 INSERT ... ON CONFLICT DO UPDATE 累加。 +// +// 冲突时只把 views 加一,visited_at 之外的列不动:同一访客当天第二次访问 +// 只是「同一行还来了」,UV 不变、PV +1。 +func (db *boxViews) RecordBatch(ctx context.Context, opts []RecordBoxViewOptions) error { + if len(opts) == 0 { + return nil + } + + now := time.Now() + rows := make([]BoxView, 0, len(opts)) + for _, o := range opts { + if o.UserID == 0 || o.Visitor == "" { + // 缺用户或访客标识的行没有统计价值,直接丢弃: + // 空 Visitor 会让同一箱所有匿名访问挤进同一行,把 UV 压成 1。 + continue + } + rows = append(rows, BoxView{ + UserID: o.UserID, + ViewedOn: dayOnly(o.ViewedOn), + Visitor: o.Visitor, + Views: 1, + CreatedAt: now, + UpdatedAt: now, + }) + } + if len(rows) == 0 { + return nil + } + + // 同一批里可能出现同一个 (user, day, visitor) 多次(并发请求攒到一次 flush): + // PostgreSQL 的 ON CONFLICT 不支持在同一条 INSERT 里两次命中同一行 + // ("ON CONFLICT DO UPDATE command cannot affect row a second time")。 + // 所以先在内存里按唯一键合并,把次数累加到 Views 上。 + merged := make([]BoxView, 0, len(rows)) + index := make(map[[3]interface{}]int, len(rows)) + for _, r := range rows { + key := [3]interface{}{r.UserID, r.ViewedOn.Format("2006-01-02"), r.Visitor} + if i, ok := index[key]; ok { + merged[i].Views += r.Views + continue + } + index[key] = len(merged) + merged = append(merged, r) + } + + err := db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "viewed_on"}, {Name: "visitor"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + // 表名限定:PG 里裸 views 在 INSERT ... ON CONFLICT 的 SET 子句里 + // 可能被当成目标列的别名解析,加上表名前缀最稳。 + "views": gorm.Expr("box_views.views + excluded.views"), + "updated_at": now, + }), + }).Create(&merged).Error + if err != nil { + return errors.Wrap(err, "upsert box views") + } + return nil +} + +func (db *boxViews) Daily(ctx context.Context, userID uint, from, to time.Time) ([]BoxViewDay, error) { + var days []BoxViewDay + err := db.WithContext(ctx).Model(&BoxView{}). + Select("viewed_on, SUM(views) AS pv, COUNT(*) AS uv"). + Where("user_id = ? AND viewed_on >= ? AND viewed_on <= ?", userID, dayOnly(from), dayOnly(to)). + Group("viewed_on"). + Order("viewed_on"). + Scan(&days).Error + if err != nil { + return nil, errors.Wrap(err, "aggregate box view days") + } + return days, nil +} + +func (db *boxViews) TopDays(ctx context.Context, userID uint, limit int) ([]BoxViewDay, error) { + if limit <= 0 { + limit = 1 + } + var days []BoxViewDay + err := db.WithContext(ctx).Model(&BoxView{}). + Select("viewed_on, SUM(views) AS pv, COUNT(*) AS uv"). + Where("user_id = ?", userID). + Group("viewed_on"). + Order("pv DESC"). + Limit(limit). + Scan(&days).Error + if err != nil { + return nil, errors.Wrap(err, "top box view days") + } + return days, nil +} + +func (db *boxViews) FirstDay(ctx context.Context, userID uint) (time.Time, error) { + var day *time.Time + err := db.WithContext(ctx).Model(&BoxView{}). + Select("MIN(viewed_on)"). + Where("user_id = ?", userID). + Scan(&day).Error + if err != nil { + return time.Time{}, errors.Wrap(err, "first box view day") + } + if day == nil { + return time.Time{}, nil + } + return *day, nil +} + +func (db *boxViews) Totals(ctx context.Context, userID uint) (int64, int64, error) { + var row struct { + PV int64 `gorm:"column:pv"` + UV int64 `gorm:"column:uv"` + } + err := db.WithContext(ctx).Model(&BoxView{}). + Select("COALESCE(SUM(views), 0) AS pv, COUNT(*) AS uv"). + Where("user_id = ?", userID). + Scan(&row).Error + if err != nil { + return 0, 0, errors.Wrap(err, "total box views") + } + return row.PV, row.UV, nil +} + +// Purge 删除 before 之前的访问明细。返回删除行数。 +// +// 用 Delete 而非 Unscoped 也没关系 —— BoxView 没有 DeletedAt 字段, +// GORM 看不到软删除标记,会直接发 DELETE。 +func (db *boxViews) Purge(ctx context.Context, before time.Time) (int64, error) { + res := db.WithContext(ctx). + Where("viewed_on < ?", dayOnly(before)). + Delete(&BoxView{}) + if res.Error != nil { + return 0, errors.Wrap(res.Error, "purge box views") + } + return res.RowsAffected, nil +} \ No newline at end of file diff --git a/internal/db/box_views_test.go b/internal/db/box_views_test.go new file mode 100644 index 0000000..38af7fc --- /dev/null +++ b/internal/db/box_views_test.go @@ -0,0 +1,131 @@ +// Copyright 2026 tamakyi. 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" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestBoxViews(t *testing.T) { + db, cleanup := newTestDB(t) + ctx := context.Background() + store := NewBoxViewsStore(db) + + for _, tc := range []struct { + name string + test func(t *testing.T, ctx context.Context, store BoxViewsStore) + }{ + {"BatchMergesSameKey", testBoxViewsBatchMergesSameKey}, + {"AccumulatesAcrossCalls", testBoxViewsAccumulatesAcrossCalls}, + {"DailyAndTotals", testBoxViewsDailyAndTotals}, + {"PurgeKeepsRecent", testBoxViewsPurgeKeepsRecent}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Cleanup(func() { + if err := cleanup("box_views"); err != nil { + t.Fatal(err) + } + }) + tc.test(t, ctx, store) + }) + } +} + +// 同一批里同 (user, day, visitor) 出现多次时,必须先在内存合并再写。 +// +// PostgreSQL 的 ON CONFLICT DO UPDATE 不允许同一条 INSERT 两次命中同一行 +// ("ON CONFLICT DO UPDATE command cannot affect row a second time"), +// 不合并的话记录器一次 flush 就会整批失败 —— 而 flush 里恰好可能包含 +// 同一访客在 2 秒内刷新三次的记录。 +func testBoxViewsBatchMergesSameKey(t *testing.T, ctx context.Context, store BoxViewsStore) { + now := time.Date(2026, 10, 29, 15, 4, 5, 0, time.Local) + + // 同一访客三次 + 另一个访客一次,全在同一批里 + err := store.RecordBatch(ctx, []RecordBoxViewOptions{ + {UserID: 7, ViewedOn: now, Visitor: "aaaa"}, + {UserID: 7, ViewedOn: now, Visitor: "aaaa"}, + {UserID: 7, ViewedOn: now, Visitor: "aaaa"}, + {UserID: 7, ViewedOn: now, Visitor: "bbbb"}, + }) + require.NoError(t, err) + + pv, uv, err := store.Totals(ctx, 7) + require.NoError(t, err) + require.Equal(t, int64(4), pv, "三次重复访问应累加进 views") + require.Equal(t, int64(2), uv, "同一访客当天只算一个 UV") +} + +func testBoxViewsAccumulatesAcrossCalls(t *testing.T, ctx context.Context, store BoxViewsStore) { + day := time.Date(2026, 10, 28, 9, 0, 0, 0, time.Local) + + require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "x"})) + require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "x"})) + require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "y"})) + + pv, uv, err := store.Totals(ctx, 3) + require.NoError(t, err) + require.Equal(t, int64(3), pv) + require.Equal(t, int64(2), uv) +} + +func testBoxViewsDailyAndTotals(t *testing.T, ctx context.Context, store BoxViewsStore) { + base := time.Date(2026, 10, 20, 12, 0, 0, 0, time.Local) + + // 20 日:2 访客共 3 次;21 日:1 访客 1 次;22 日:无记录 + require.NoError(t, store.RecordBatch(ctx, []RecordBoxViewOptions{ + {UserID: 11, ViewedOn: base, Visitor: "a"}, + {UserID: 11, ViewedOn: base, Visitor: "a"}, + {UserID: 11, ViewedOn: base, Visitor: "b"}, + {UserID: 11, ViewedOn: base.AddDate(0, 0, 1), Visitor: "a"}, + })) + // 另一个箱主的数据不能串进来 + require.NoError(t, store.Record(ctx, RecordBoxViewOptions{ + UserID: 12, ViewedOn: base, Visitor: "a", + })) + + days, err := store.Daily(ctx, 11, base, base.AddDate(0, 0, 2)) + require.NoError(t, err) + require.Len(t, days, 2, "没有记录的那天不返回,由上层补零") + + require.Equal(t, "2026-10-20", days[0].Day.Format("2006-01-02")) + require.Equal(t, int64(3), days[0].PV) + require.Equal(t, int64(2), days[0].UV) + + require.Equal(t, "2026-10-21", days[1].Day.Format("2006-01-02")) + require.Equal(t, int64(1), days[1].PV) + require.Equal(t, int64(1), days[1].UV) + + pv, uv, err := store.Totals(ctx, 11) + require.NoError(t, err) + require.Equal(t, int64(4), pv) + require.Equal(t, int64(3), uv) + + first, err := store.FirstDay(ctx, 11) + require.NoError(t, err) + require.Equal(t, "2026-10-20", first.Format("2006-01-02")) +} + +func testBoxViewsPurgeKeepsRecent(t *testing.T, ctx context.Context, store BoxViewsStore) { + old := time.Now().AddDate(0, 0, -30) + recent := time.Now() + + require.NoError(t, store.RecordBatch(ctx, []RecordBoxViewOptions{ + {UserID: 5, ViewedOn: old, Visitor: "a"}, + {UserID: 5, ViewedOn: recent, Visitor: "b"}, + })) + + n, err := store.Purge(ctx, time.Now().AddDate(0, 0, -7)) + require.NoError(t, err) + require.Equal(t, int64(1), n) + + pv, uv, err := store.Totals(ctx, 5) + require.NoError(t, err) + require.Equal(t, int64(1), pv, "只保留保留期内的行") + require.Equal(t, int64(1), uv) +} diff --git a/internal/db/db.go b/internal/db/db.go index 9db47a9..add0d1a 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -47,6 +47,8 @@ var AllTables = []interface{}{ &HoneypotHit{}, // 自动封禁记录(内存态封禁的落库账本,用于事后解释与取证) &BanRecord{}, + // 箱主数据面板的访问流水(每 访客×天×箱 一行,Unscoped 语义,见 BoxView 注释) + &BoxView{}, } func Init(typ, dsn string) (*gorm.DB, error) { @@ -166,6 +168,8 @@ func Init(typ, dsn string) (*gorm.DB, error) { Honeypots = NewHoneypotStore(db) BanRecords = NewBanRecordsStore(db) Analytics = NewSiteAnalyticsStore(db) + BoxViews = NewBoxViewsStore(db) + BoxAnalytics = NewBoxAnalyticsStore(db) // 旧版本把消息/密码明文存在 honeypot_hits 上;结构体已删掉这些字段, // 但 AutoMigrate 不会删除既有列 —— 老库升级上来后历史明文仍在。 diff --git a/internal/i18n/dict_en.go b/internal/i18n/dict_en.go index c11ac5e..f990d8a 100644 --- a/internal/i18n/dict_en.go +++ b/internal/i18n/dict_en.go @@ -1054,6 +1054,50 @@ var enDict = map[string]string{ "stats.weekday.6": "Saturday", "stats.weekday.7": "Sunday", + // Owner insights panel (/user/insights) + "insights.title": "Insights", + "insights.heading": "Your insights", + "insights.lead": "These numbers are yours alone: how many people opened your box, how many questions arrived, and how many are still waiting. Everything is aggregated — no visitor or asker is ever identified.", + "insights.back": "Back to settings", + "insights.entryTitle": "Insights", + "insights.entryDesc": "See how many people visit your box and how your questions are doing", + "insights.summary": "At a glance", + "insights.noViews": "No visits recorded yet. Numbers will appear once someone opens your box.", + "insights.today": "Today", + "insights.week": "Last 7 days", + "insights.month": "Last 30 days", + "insights.newQuestions": "new questions", + "insights.pv": "views", + "insights.uv": "visitors", + "insights.trendTitle": "Views over the last 30 days", + "insights.trendNote": "One bar per day: the light part is page views, the dark part is unique visitors that day. Hover a bar for its views / visitors / questions.", + "insights.legendPV": "Page views", + "insights.legendUV": "Unique visitors (per day)", + "insights.legendHover": "Hover for daily details", + "insights.hourTitle": "When people show up", + "insights.hourNote": "When questions arrived during the last 30 days. The brightest bar is your busiest hour.", + "insights.funnelTitle": "Question handling", + "insights.funnelNote": "All questions split into answered / read but unanswered / untouched.", + "insights.funnelTotal": "All questions", + "insights.funnelAnswered": "Answered", + "insights.funnelReadOnly": "Read, not answered", + "insights.funnelUnread": "Not read", + "insights.answerRate": "Answer rate: answered ÷ all questions", + "insights.privacyTitle": "About these numbers", + "insights.privacyNote": "Visit stats only record \"someone visited N times on a given day\". A visitor is an IP hash with a per-day salt — no IP, no user agent, no page path is stored, and the same person cannot be linked across days. The 7-day / 30-day visitor counts are therefore the sum of daily uniques: someone visiting on several days counts several times. Your own visits to your own box are excluded.", + + "insights.insight.empty": "No data yet. This page comes alive once you get your first question or someone opens your box.", + "insights.insight.weekViews": "%s views from %s visitors in the last 7 days.", + "insights.insight.conversion": "About %s%% of visitors left a message in the last 30 days.", + "insights.insight.bestDay": "Your busiest day was %s with %s views.", + "insights.insight.peakHour": "Questions cluster around %d:00.", + "insights.insight.answerRate": "You have answered %s%% of all questions.", + "insights.insight.fastReply": "%s%% of your answers went out within 24 hours.", + "insights.insight.unread": "%s questions are still waiting.", + "insights.insight.trendUp": "Views this week are up %s%% on last week.", + "insights.insight.trendDown": "Views this week are down %s%% on last week.", + "insights.insight.trendFlat": "Views this week are about the same as last week.", + "stats.foot.updated": "Snapshot:", "stats.foot.prevMonth": "Last question:", "stats.foot.prevAnswer": "Last answer:", diff --git a/internal/i18n/dict_ja.go b/internal/i18n/dict_ja.go index 641e808..c069f87 100644 --- a/internal/i18n/dict_ja.go +++ b/internal/i18n/dict_ja.go @@ -1053,6 +1053,50 @@ var jaDict = map[string]string{ "stats.weekday.6": "土曜日", "stats.weekday.7": "日曜日", + // 質問箱オーナーのインサイト(/user/insights) + "insights.title": "インサイト", + "insights.heading": "あなたのインサイト", + "insights.lead": "この数字はあなただけのものです。何人が質問箱を開いたか、いくつ質問が届いたか、まだ手つかずの質問がいくつあるか。すべて集計値で、訪問者や質問者の身元は一切含みません。", + "insights.back": "設定に戻る", + "insights.entryTitle": "インサイト", + "insights.entryDesc": "質問箱に何人来たか、質問の対応状況を見る", + "insights.summary": "ひと目で", + "insights.noViews": "まだ訪問の記録がありません。誰かが質問箱を開くと数字が出はじめます。", + "insights.today": "今日", + "insights.week": "過去 7 日間", + "insights.month": "過去 30 日間", + "insights.newQuestions": "新着の質問", + "insights.pv": "回表示", + "insights.uv": "人", + "insights.trendTitle": "過去 30 日間の閲覧推移", + "insights.trendNote": "1 本が 1 日分。淡い部分が閲覧数、濃い部分がその日の重複を除いた訪問者数です。棒にカーソルを合わせると当日の閲覧/訪問者/質問数が出ます。", + "insights.legendPV": "閲覧数(PV)", + "insights.legendUV": "ユニーク訪問者(日ごとに重複排除)", + "insights.legendHover": "カーソルで当日の内訳", + "insights.hourTitle": "いつ来ているか", + "insights.hourNote": "過去 30 日に届いた質問の時間帯分布。最も明るい棒が一番忙しい時間帯です。", + "insights.funnelTitle": "質問の対応状況", + "insights.funnelNote": "すべての質問を「回答済み/既読・未回答/未読」に分けたものです。", + "insights.funnelTotal": "すべての質問", + "insights.funnelAnswered": "回答済み", + "insights.funnelReadOnly": "既読・未回答", + "insights.funnelUnread": "未読", + "insights.answerRate": "回答率:回答済み ÷ 全質問", + "insights.privacyTitle": "この数字について", + "insights.privacyNote": "訪問統計は「ある日に何回来たか」だけを記録します。訪問者の識別子は日ごとに塩を変えた IP ハッシュで、IP・UA・閲覧ページは保存しません。そのため日をまたいで同一人物を紐付けることはできず、7 日/30 日の訪問者数は各日の重複排除後の合計です(同じ人が数日来るとその人数分カウントされます)。質問箱の持ち主自身のアクセスは含みません。", + + "insights.insight.empty": "まだデータがありません。最初の質問が届くか、誰かが質問箱を開くと表示されはじめます。", + "insights.insight.weekViews": "過去 7 日間に %s 回の閲覧、%s 人の訪問者。", + "insights.insight.conversion": "過去 30 日間、訪問者の約 %s%% が質問を残しました。", + "insights.insight.bestDay": "一番にぎわったのは %s で、%s 回閲覧されました。", + "insights.insight.peakHour": "質問は %d 時前後に集中しています。", + "insights.insight.answerRate": "すべての質問のうち %s%% に回答済みです。", + "insights.insight.fastReply": "回答の %s%% は 24 時間以内に返しています。", + "insights.insight.unread": "未対応の質問が %s 件あります。", + "insights.insight.trendUp": "今週の閲覧は先週より %s%% 増えました。", + "insights.insight.trendDown": "今週の閲覧は先週より %s%% 減りました。", + "insights.insight.trendFlat": "今週の閲覧は先週とほぼ同じです。", + "stats.foot.updated": "集計時刻:", "stats.foot.prevMonth": "最後の質問:", "stats.foot.prevAnswer": "最後の回答:", diff --git a/internal/i18n/dict_zh_cn.go b/internal/i18n/dict_zh_cn.go index 01ca1f7..016983c 100644 --- a/internal/i18n/dict_zh_cn.go +++ b/internal/i18n/dict_zh_cn.go @@ -1054,6 +1054,50 @@ var zhCNDict = map[string]string{ "stats.weekday.6": "周六", "stats.weekday.7": "周日", + // 箱主数据面板(/user/insights) + "insights.title": "数据面板", + "insights.heading": "你的数据面板", + "insights.lead": "这些数字只属于你自己:有多少人打开过你的提问箱、你收到了多少提问、还有多少没处理。全部是聚合结果,不含任何一位访客或提问者的身份。", + "insights.back": "返回设置", + "insights.entryTitle": "数据面板", + "insights.entryDesc": "看看有多少人来过你的提问箱、提问处理得怎么样", + "insights.summary": "一眼看懂", + "insights.noViews": "还没有访客记录。有人打开过你的提问箱之后,这里就会开始有数字。", + "insights.today": "今天", + "insights.week": "近 7 天", + "insights.month": "近 30 天", + "insights.newQuestions": "新提问", + "insights.pv": "次浏览", + "insights.uv": "位访客", + "insights.trendTitle": "近 30 天浏览趋势", + "insights.trendNote": "每根柱子是一天,浅色是浏览量,深色是当天去重后的访客数。把鼠标停在柱子上能看到当天的浏览 / 访客 / 提问。", + "insights.legendPV": "浏览量(PV)", + "insights.legendUV": "独立访客(按日去重)", + "insights.legendHover": "悬停查看当天明细", + "insights.hourTitle": "大家什么时候来", + "insights.hourNote": "近 30 天收到的提问分布在一天里的哪个时段,最亮的一根是你最忙的时段。", + "insights.funnelTitle": "提问处理情况", + "insights.funnelNote": "全部提问按「已回答 / 已读未答 / 未读未答」三类拆开。", + "insights.funnelTotal": "全部提问", + "insights.funnelAnswered": "已回答", + "insights.funnelReadOnly": "已读未答", + "insights.funnelUnread": "未读未答", + "insights.answerRate": "回答率:已回答 ÷ 全部提问", + "insights.privacyTitle": "关于这些数字", + "insights.privacyNote": "访客统计只记录「某天有人来过几次」。访客身份是每天更换盐值的 IP 哈希,不保存 IP、不保存 UA、也不保存访问了哪个页面。因此跨天的同一个人无法被关联,近 7 天 / 30 天的访客数是各天去重后相加 —— 同一个人连续几天来访会被记成几个人。箱主自己打开自己的提问箱不计入统计。", + + "insights.insight.empty": "还没有任何数据。等你收到第一条提问,或者有人打开过你的提问箱,这里就会有变化。", + "insights.insight.weekViews": "近 7 天有 %s 次浏览,%s 位访客。", + "insights.insight.conversion": "近 30 天的访客中,约有 %s%% 留下了提问。", + "insights.insight.bestDay": "最热闹的一天是 %s,有 %s 次浏览。", + "insights.insight.peakHour": "提问最集中在 %d 点前后。", + "insights.insight.answerRate": "你已经回答了 %s%% 的提问。", + "insights.insight.fastReply": "已回答的提问里,%s%% 是在 24 小时内回复的。", + "insights.insight.unread": "还有 %s 条提问没处理。", + "insights.insight.trendUp": "本周浏览比上周多了 %s%%。", + "insights.insight.trendDown": "本周浏览比上周少了 %s%%。", + "insights.insight.trendFlat": "本周浏览与上周差不多。", + "stats.foot.updated": "快照时间:", "stats.foot.prevMonth": "最后一条提问:", "stats.foot.prevAnswer": "最后一条回答:", diff --git a/internal/i18n/dict_zh_tw.go b/internal/i18n/dict_zh_tw.go index 73692de..11ed2aa 100644 --- a/internal/i18n/dict_zh_tw.go +++ b/internal/i18n/dict_zh_tw.go @@ -1053,6 +1053,50 @@ var zhTWDict = map[string]string{ "stats.weekday.6": "週六", "stats.weekday.7": "週日", + // 箱主數據面板(/user/insights) + "insights.title": "數據面板", + "insights.heading": "你的數據面板", + "insights.lead": "這些數字只屬於你自己:有多少人開啟過你的提問箱、你收到了多少提問、還有多少沒處理。全部是彙總結果,不含任何一位訪客或提問者的身分。", + "insights.back": "返回設定", + "insights.entryTitle": "數據面板", + "insights.entryDesc": "看看有多少人來過你的提問箱、提問處理得怎麼樣", + "insights.summary": "一眼看懂", + "insights.noViews": "還沒有訪客紀錄。有人開啟過你的提問箱之後,這裡就會開始有數字。", + "insights.today": "今天", + "insights.week": "近 7 天", + "insights.month": "近 30 天", + "insights.newQuestions": "新提問", + "insights.pv": "次瀏覽", + "insights.uv": "位訪客", + "insights.trendTitle": "近 30 天瀏覽趨勢", + "insights.trendNote": "每根柱子是一天,淺色是瀏覽量,深色是當天去重後的訪客數。把游標停在柱子上能看到當天的瀏覽/訪客/提問。", + "insights.legendPV": "瀏覽量(PV)", + "insights.legendUV": "獨立訪客(按日去重)", + "insights.legendHover": "停留查看當天明細", + "insights.hourTitle": "大家什麼時候來", + "insights.hourNote": "近 30 天收到的提問分布在一天裡的哪個時段,最亮的一根是你最忙的時段。", + "insights.funnelTitle": "提問處理情況", + "insights.funnelNote": "全部提問按「已回答/已讀未答/未讀未答」三類拆開。", + "insights.funnelTotal": "全部提問", + "insights.funnelAnswered": "已回答", + "insights.funnelReadOnly": "已讀未答", + "insights.funnelUnread": "未讀未答", + "insights.answerRate": "回答率:已回答 ÷ 全部提問", + "insights.privacyTitle": "關於這些數字", + "insights.privacyNote": "訪客統計只記錄「某天有人來過幾次」。訪客身分是每天更換鹽值的 IP 雜湊,不保存 IP、不保存 UA、也不保存造訪了哪個頁面。因此跨天的同一個人無法被關聯,近 7 天/30 天的訪客數是各天去重後相加 —— 同一個人連續幾天造訪會被記成幾個人。箱主自己開啟自己的提問箱不計入統計。", + + "insights.insight.empty": "還沒有任何資料。等你收到第一條提問,或有人開啟過你的提問箱,這裡就會有變化。", + "insights.insight.weekViews": "近 7 天有 %s 次瀏覽,%s 位訪客。", + "insights.insight.conversion": "近 30 天的訪客中,約有 %s%% 留下了提問。", + "insights.insight.bestDay": "最熱鬧的一天是 %s,有 %s 次瀏覽。", + "insights.insight.peakHour": "提問最集中在 %d 點前後。", + "insights.insight.answerRate": "你已經回答了 %s%% 的提問。", + "insights.insight.fastReply": "已回答的提問裡,%s%% 是在 24 小時內回覆的。", + "insights.insight.unread": "還有 %s 條提問沒處理。", + "insights.insight.trendUp": "本週瀏覽比上週多了 %s%%。", + "insights.insight.trendDown": "本週瀏覽比上週少了 %s%%。", + "insights.insight.trendFlat": "本週瀏覽與上週差不多。", + "stats.foot.updated": "快照時間:", "stats.foot.prevMonth": "最後一則提問:", "stats.foot.prevAnswer": "最後一則回答:", diff --git a/internal/insights/page.go b/internal/insights/page.go new file mode 100644 index 0000000..6235b41 --- /dev/null +++ b/internal/insights/page.go @@ -0,0 +1,438 @@ +// Copyright 2026 tamakyi. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// Package insights 为箱主生成「自己的数据面板」。 +// +// 与 internal/stats 的分工:stats 是全站公开页(任何人都能看、不含单箱维度、 +// 结果按自然日缓存);本包只看当前登录用户自己的箱,数据实时,只对箱主可见。 +package insights + +import ( + "context" + "fmt" + "math" + "time" + + "github.com/sirupsen/logrus" + + "github.com/tamakyi/TamaBox/internal/db" + "github.com/tamakyi/TamaBox/internal/i18n" +) + +const ( + // TrendDays 趋势窗口天数(含今天)。 + TrendDays = 30 + // WeekDays「本周」窗口与「上周」对比窗口的长度。 + WeekDays = 7 +) + +// Page 是箱主数据面板的渲染模型。 +type Page struct { + Lang string + + // HasViews 表示是否已经有任何一条访问记录。新箱子的面板会显示引导文案, + // 而不是一排 0 —— 后者看起来像统计坏了。 + HasViews bool + Since time.Time + + Today Window + Week Window + Month Window + All Window + + Days []Day + // Hours 24 小时来访分布(近 30 天的浏览)。 + Hours [24]Hour + PeakHour int + PeakHourN int64 + + Funnel *db.BoxFunnel + + // 派生指标(百分比,0~100)。 + Conversion float64 // 近 30 天「提问数 / UV」 + AnswerRate float64 // 全期回答率 + FastReply float64 // 全期 24 小时内回答占比 + + BestDay *Day + // AnswerRateText 是 AnswerRate 的展示形式:整数不带小数点(100%,而不是 100.0%)。 + AnswerRateText string + // Conclusions 自动生成的一两句话结论,已本地化。 + Conclusions []string +} + +// Window 一段时间内的汇总。 +type Window struct { + PV int64 + UV int64 + Questions int64 + Answers int64 + // PrevPV / HasPrev 用于跟上一个等长周期比较(只有 Week 有值)。 + PrevPV int64 + HasPrev bool + // Delta 相对 PrevPV 的变化百分比。PrevPV 为 0 时无意义,用 HasDelta 标记。 + Delta float64 + HasDelta bool +} + +// Day 趋势图里的一天。 +type Day struct { + Key string // 2026-10-29 + Label string // 10-29 + Weekday string + PV int64 + UV int64 + Questions int64 + Answers int64 + // 高度百分比(0~100),直接当 CSS 高度用。 + PVHeight float64 + UVHeight float64 + QHeight float64 + IsToday bool + IsEmpty bool +} + +// Hour 时段分布里的一个柱子。 +type Hour struct { + Hour int + N int64 + Height float64 +} + +// Build 装配箱主 userID 的数据面板。 +// +// 任何一个子查询失败都只让对应区块缺数据,不返回错误:面板是「看个大概」的页面, +// 因为一条聚合 SQL 挂了就整页 500,对箱主没有任何帮助。 +func Build(ctx context.Context, lang string, userID uint, now time.Time) *Page { + p := &Page{Lang: lang, Funnel: &db.BoxFunnel{}} + + today := dayOnly(now) + monthFrom := today.AddDate(0, 0, -(TrendDays - 1)) + weekFrom := today.AddDate(0, 0, -(WeekDays - 1)) + prevWeekFrom := today.AddDate(0, 0, -(2*WeekDays - 1)) + prevWeekTo := today.AddDate(0, 0, -WeekDays) + + // 先无条件铺好 30 天空骨架:访问统计和提问统计任何一侧缺数据, + // 趋势图都还能画出坐标轴与空柱,而不是整块消失。 + p.buildDays(nil, today) + + if db.BoxViews != nil { + days, err := db.BoxViews.Daily(ctx, userID, monthFrom, today) + if err != nil { + logrus.WithContext(ctx).WithError(err).Warn("加载箱主访问趋势失败") + } else { + p.fillViews(days) + } + + if pv, uv, err := db.BoxViews.Totals(ctx, userID); err != nil { + logrus.WithContext(ctx).WithError(err).Warn("加载箱主累计访问失败") + } else { + p.All.PV, p.All.UV = pv, uv + p.HasViews = pv > 0 + } + + if since, err := db.BoxViews.FirstDay(ctx, userID); err != nil { + logrus.WithContext(ctx).WithError(err).Warn("加载箱主首次访问时间失败") + } else { + p.Since = since + } + } + + if db.BoxAnalytics != nil { + if trend, err := db.BoxAnalytics.BoxTrend(ctx, userID, monthFrom, today); err != nil { + logrus.WithContext(ctx).WithError(err).Warn("加载箱主提问趋势失败") + } else { + p.fillQuestions(trend) + } + + if hours, err := db.BoxAnalytics.BoxHours(ctx, userID, monthFrom); err != nil { + logrus.WithContext(ctx).WithError(err).Warn("加载箱主时段分布失败") + } else { + p.buildHours(hours) + } + + if funnel, err := db.BoxAnalytics.BoxFunnel(ctx, userID); err != nil { + logrus.WithContext(ctx).WithError(err).Warn("加载箱主提问漏斗失败") + } else { + p.Funnel = funnel + } + } + + p.aggregateWindows(today, weekFrom, prevWeekFrom, prevWeekTo) + p.computeRates() + return p +} + +// dayOnly 截断到当天 0 点,保留传入时区。 +func dayOnly(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} + +// buildDays 铺出完整的 30 天序列(不含任何计数)。 +func (p *Page) buildDays(_ []db.BoxViewDay, today time.Time) { + from := today.AddDate(0, 0, -(TrendDays - 1)) + p.Days = make([]Day, 0, TrendDays) + for i := 0; i < TrendDays; i++ { + d := from.AddDate(0, 0, i) + p.Days = append(p.Days, Day{ + Key: d.Format("2006-01-02"), + Label: d.Format("01-02"), + Weekday: fmt.Sprintf("stats.wd.%d", int(d.Weekday())), + IsToday: sameDay(d, today), + IsEmpty: true, + }) + } +} + +// fillViews 把访问量填进已有骨架。 +func (p *Page) fillViews(rows []db.BoxViewDay) { + index := make(map[string]int, len(p.Days)) + for i, d := range p.Days { + index[d.Key] = i + } + for _, r := range rows { + if i, ok := index[r.Day.Format("2006-01-02")]; ok { + p.Days[i].PV = r.PV + p.Days[i].UV = r.UV + p.Days[i].IsEmpty = r.PV == 0 && p.Days[i].Questions == 0 + } + } + p.recomputeHeights() +} + +// fillQuestions 把提问 / 回答数填进已有骨架。 +func (p *Page) fillQuestions(rows []db.BoxDayCount) { + index := make(map[string]int, len(p.Days)) + for i, d := range p.Days { + index[d.Key] = i + } + for _, r := range rows { + if i, ok := index[r.Day.Format("2006-01-02")]; ok { + p.Days[i].Questions = r.Questions + p.Days[i].Answers = r.Answers + p.Days[i].IsEmpty = p.Days[i].PV == 0 && r.Questions == 0 + } + } + p.recomputeHeights() +} + +// recomputeHeights 按窗口内最大值归一化成百分比高度。 +// +// 最大值为 0 时全部置 0(而不是除零),模板会把柱高 0 渲染成一条淡淡的底线。 +func (p *Page) recomputeHeights() { + var maxPV, maxQ int64 + for _, d := range p.Days { + if d.PV > maxPV { + maxPV = d.PV + } + if d.UV > maxPV { + maxPV = d.UV + } + if d.Questions > maxQ { + maxQ = d.Questions + } + } + for i := range p.Days { + d := &p.Days[i] + if maxPV > 0 { + d.PVHeight = pct(d.PV, maxPV) + d.UVHeight = pct(d.UV, maxPV) + } + if maxQ > 0 { + d.QHeight = pct(d.Questions, maxQ) + } + } +} + +func (p *Page) buildHours(counts [24]int64) { + var max int64 + for _, n := range counts { + if n > max { + max = n + } + } + for h := 0; h < 24; h++ { + p.Hours[h] = Hour{Hour: h, N: counts[h]} + if max > 0 { + p.Hours[h].Height = pct(counts[h], max) + } + if counts[h] > p.PeakHourN { + p.PeakHourN = counts[h] + p.PeakHour = h + } + } +} + +// aggregateWindows 从日序列切出今日 / 本周 / 本月窗口,并算上周对比。 +func (p *Page) aggregateWindows(today, weekFrom, prevFrom, prevTo time.Time) { + for _, d := range p.Days { + t, err := time.ParseInLocation("2006-01-02", d.Key, today.Location()) + if err != nil { + continue + } + p.Month.PV += d.PV + p.Month.UV += d.UV + p.Month.Questions += d.Questions + p.Month.Answers += d.Answers + + if !t.Before(weekFrom) { + p.Week.PV += d.PV + p.Week.UV += d.UV + p.Week.Questions += d.Questions + p.Week.Answers += d.Answers + } + if !t.Before(prevFrom) && !t.After(prevTo) { + p.Week.PrevPV += d.PV + } + if sameDay(t, today) { + p.Today.PV = d.PV + p.Today.UV = d.UV + p.Today.Questions = d.Questions + p.Today.Answers = d.Answers + } + + if p.BestDay == nil || d.PV > p.BestDay.PV { + cp := d + p.BestDay = &cp + } + } + + // 全期提问 / 回答来自漏斗(它扫的是整张表,不受 30 天窗口限制)。 + if p.Funnel != nil { + p.All.Questions = p.Funnel.Total + p.All.Answers = p.Funnel.Answered + } + + p.Week.HasPrev = true + if p.Week.PrevPV > 0 { + p.Week.HasDelta = true + p.Week.Delta = float64(p.Week.PV-p.Week.PrevPV) / float64(p.Week.PrevPV) * 100 + } + // 上周一次访问都没有时,「涨了 100%」这类结论毫无意义 —— 直接不显示对比。 +} + +func (p *Page) computeRates() { + if p.Month.UV > 0 { + p.Conversion = float64(p.Month.Questions) / float64(p.Month.UV) * 100 + } + if p.Funnel != nil && p.Funnel.Total > 0 { + p.AnswerRate = float64(p.Funnel.Answered) / float64(p.Funnel.Total) * 100 + if p.Funnel.Answered > 0 { + p.FastReply = float64(p.Funnel.RepliedIn24h) / float64(p.Funnel.Answered) * 100 + } + } + p.AnswerRateText = formatPct(p.AnswerRate) +} + +// formatPct 把百分比渲染成「100%」或「33.3%」——整数值不补 .0。 +// +// 面板上「100.0%」这种写法会让人下意识去找那个小数位,而它并不携带信息。 +func formatPct(v float64) string { + if v == math.Trunc(v) { + return fmt.Sprintf("%.0f", v) + } + return fmt.Sprintf("%.1f", v) +} + +// pct 返回 n 相对 max 的百分比,保留一位小数。 +func pct(n, max int64) float64 { + if max <= 0 { + return 0 + } + v := float64(n) / float64(max) * 100 + // 四舍五入到 0.1,避免出现一长串小数把 CSS 撑得很长。 + return float64(int(v*10+0.5)) / 10 +} + +func sameDay(a, b time.Time) bool { + ay, am, ad := a.Date() + by, bm, bd := b.Date() + return ay == by && am == bm && ad == bd +} + +// formatDay 把日期格式化成「10-29」这种短标签,供文案使用。 +func formatDay(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("01-02") +} + +// FormatInsights 生成面板顶部的自动结论。 +// +// 放在这里而不是模板里:结论需要做「有没有数据」「除零」这类判断, +// 全塞进模板会让它变成一堆 eq/if 的嵌套。 +func (p *Page) FormatInsights() { + lang := p.Lang + add := func(key string, args ...interface{}) { + text := i18n.T(lang, key, args...) + // 缺词条时 T 会返回 key 本身,别把内部 key 印到页面上。 + if text == "" || text == key { + return + } + p.Conclusions = append(p.Conclusions, text) + } + + if !p.HasViews && p.Funnel.Total == 0 { + add("insights.insight.empty") + return + } + + if p.HasViews { + add("insights.insight.weekViews", formatInt(p.Week.PV), formatInt(p.Week.UV)) + } + + // 样本太小的时候「转化率」没有解释力:1 位访客提了 1 个问题就是 100%, + // 这种数字只会误导箱主,不如不说。 + if p.Month.UV >= 5 && p.Month.Questions > 0 { + add("insights.insight.conversion", formatPct(p.Conversion)) + } + + if p.BestDay != nil && p.BestDay.PV > 0 { + add("insights.insight.bestDay", p.BestDay.Label, formatInt(p.BestDay.PV)) + } + + if p.PeakHourN > 0 { + add("insights.insight.peakHour", p.PeakHour) + } + + if p.Funnel.Total > 0 { + add("insights.insight.answerRate", formatPct(p.AnswerRate)) + } + if p.FastReply > 0 { + add("insights.insight.fastReply", fmt.Sprintf("%.0f", p.FastReply)) + } + if p.Funnel.Unread > 0 { + add("insights.insight.unread", formatInt(p.Funnel.Unread)) + } + + if p.Week.HasDelta { + switch { + case p.Week.Delta >= 5: + add("insights.insight.trendUp", fmt.Sprintf("%.0f", p.Week.Delta)) + case p.Week.Delta <= -5: + add("insights.insight.trendDown", fmt.Sprintf("%.0f", -p.Week.Delta)) + default: + add("insights.insight.trendFlat") + } + } +} + +// formatInt 给数字加千分位。 +func formatInt(n int64) string { + if n < 0 { + return "-" + formatInt(-n) + } + s := fmt.Sprintf("%d", n) + if len(s) <= 3 { + return s + } + var out []byte + for i, c := range []byte(s) { + if i > 0 && (len(s)-i)%3 == 0 { + out = append(out, ',') + } + out = append(out, c) + } + return string(out) +} diff --git a/internal/insights/page_test.go b/internal/insights/page_test.go new file mode 100644 index 0000000..05041c9 --- /dev/null +++ b/internal/insights/page_test.go @@ -0,0 +1,208 @@ +// Copyright 2026 tamakyi. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package insights + +import ( + "net/http/httptest" + "testing" + "time" +) + +func TestPct(t *testing.T) { + cases := []struct { + n, max int64 + want float64 + }{ + {0, 0, 0}, // 除零:不能算出 NaN 塞进 CSS + {5, 0, 0}, // 同上 + {10, 10, 100}, // 满格 + {1, 3, 33.3}, // 四舍五入到一位小数 + {2, 3, 66.7}, + {1, 1000, 0.1}, + } + for _, c := range cases { + if got := pct(c.n, c.max); got != c.want { + t.Errorf("pct(%d, %d) = %v, want %v", c.n, c.max, got, c.want) + } + } +} + +func TestFormatInt(t *testing.T) { + cases := map[int64]string{ + 0: "0", + 7: "7", + 999: "999", + 1000: "1,000", + 12345: "12,345", + 1234567: "1,234,567", + -1234: "-1,234", + } + for in, want := range cases { + if got := formatInt(in); got != want { + t.Errorf("formatInt(%d) = %q, want %q", in, got, want) + } + } +} + +// 窗口切分是「今日 / 近 7 天 / 近 30 天」与「本周 vs 上周」的唯一实现, +// 算错不会报错、只会安静地显示错误的数字,所以单独测。 +func TestAggregateWindows(t *testing.T) { + loc := time.Local + today := time.Date(2026, 10, 29, 0, 0, 0, 0, loc) + weekFrom := today.AddDate(0, 0, -(WeekDays - 1)) // 10-23 + prevFrom := today.AddDate(0, 0, -(2*WeekDays - 1)) // 10-16 + prevTo := today.AddDate(0, 0, -WeekDays) // 10-22 + + p := &Page{} + for i := 0; i < TrendDays; i++ { + d := today.AddDate(0, 0, -(TrendDays - 1) + i) + p.Days = append(p.Days, Day{Key: d.Format("2006-01-02")}) + } + set := func(offsetDays int, pv, uv, q, a int64) { + key := today.AddDate(0, 0, offsetDays).Format("2006-01-02") + for i := range p.Days { + if p.Days[i].Key == key { + p.Days[i].PV, p.Days[i].UV = pv, uv + p.Days[i].Questions, p.Days[i].Answers = q, a + return + } + } + t.Fatalf("日期 %s 不在骨架里", key) + } + + set(0, 5, 3, 1, 0) // 今天:本周 + 今日 + set(-1, 2, 2, 0, 1) // 昨天:本周 + set(-6, 4, 1, 2, 0) // 10-23:本周起点 + set(-7, 9, 6, 0, 0) // 10-22:上周末日 + set(-13, 6, 5, 0, 0) // 10-16:上周起点 + + p.aggregateWindows(today, weekFrom, prevFrom, prevTo) + + // 本周 = 5+2+4;上周 = 9+6 + if p.Week.PV != 11 { + t.Errorf("Week.PV = %d, want 11", p.Week.PV) + } + if p.Week.PrevPV != 15 { + t.Errorf("Week.PrevPV = %d, want 15", p.Week.PrevPV) + } + if !p.Week.HasDelta { + t.Fatal("上周有数据时 HasDelta 应为 true") + } + if got, want := p.Week.Delta, float64(11-15)/15*100; got != want { + t.Errorf("Week.Delta = %v, want %v", got, want) + } + if p.Today.PV != 5 || p.Today.UV != 3 { + t.Errorf("Today = %+v, want PV 5 UV 3", p.Today) + } + if p.Month.PV != 26 { + t.Errorf("Month.PV = %d, want 26(全部 5 天之和)", p.Month.PV) + } + if p.BestDay == nil || p.BestDay.PV != 9 { + t.Errorf("BestDay = %+v, want 10-22 的 9 次", p.BestDay) + } +} + +// 上周一次访问都没有时不能给出「涨了 100%」这类结论。 +func TestAggregateWindowsNoPrev(t *testing.T) { + loc := time.Local + today := time.Date(2026, 10, 29, 0, 0, 0, 0, loc) + + p := &Page{} + for i := 0; i < TrendDays; i++ { + d := today.AddDate(0, 0, -(TrendDays - 1) + i) + p.Days = append(p.Days, Day{Key: d.Format("2006-01-02")}) + } + p.Days[len(p.Days)-1].PV = 3 + + p.aggregateWindows(today, today.AddDate(0, 0, -6), today.AddDate(0, 0, -13), today.AddDate(0, 0, -7)) + + if p.Week.HasDelta { + t.Error("上周 PV 为 0 时 HasDelta 应为 false") + } +} + +func TestSameDay(t *testing.T) { + a := time.Date(2026, 10, 29, 0, 0, 0, 0, time.Local) + b := time.Date(2026, 10, 29, 23, 59, 59, 0, time.Local) + c := time.Date(2026, 10, 30, 0, 0, 1, 0, time.Local) + if !sameDay(a, b) { + t.Error("同一天的任意时刻应判为同一天") + } + if sameDay(a, c) { + t.Error("跨天应判为不同天") + } +} + +// 访客标识必须「同日可去重、跨日不可关联」:这是面板隐私承诺的技术基础。 +func TestVisitorID(t *testing.T) { + r := NewRecorder(nil, "test-secret") // 不启动写入,仅用哈希方法 + defer r.Close() + + day1 := time.Date(2026, 10, 29, 8, 0, 0, 0, time.Local) + day1Later := time.Date(2026, 10, 29, 22, 0, 0, 0, time.Local) + day2 := time.Date(2026, 10, 30, 8, 0, 0, 0, time.Local) + + a := r.visitorID("1.2.3.4", day1) + if a != r.visitorID("1.2.3.4", day1Later) { + t.Error("同一天同一 IP 必须得到相同标识,否则 UV 去重失效") + } + if a == r.visitorID("1.2.3.4", day2) { + t.Error("跨天必须得到不同标识,否则可长期追踪同一访客") + } + if a == r.visitorID("5.6.7.8", day1) { + t.Error("不同 IP 同一天必须不同") + } + if len(a) != 32 { + t.Errorf("标识长度 = %d, want 32", len(a)) + } + // 换密钥后整体不可关联(运维轮换 xsrf_key 时历史哈希不会被重新对上) + other := NewRecorder(nil, "another-secret") + defer other.Close() + if a == other.visitorID("1.2.3.4", day1) { + t.Error("不同密钥应给出不同标识") + } +} + +func TestIsCrawler(t *testing.T) { + cases := map[string]bool{ + "": true, // 空 UA 基本是脚本 + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36": false, + "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)": true, + "curl/8.4.0": true, + "python-requests/2.31.0": true, + "Mozilla/5.0 (compatible; bingbot/2.0)": true, + "facebookexternalhit/1.1": true, + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) AppleWebKit/605.1.15 Version/17.0 Mobile Safari/604.1": false, + } + for ua, want := range cases { + if got := isCrawler(ua); got != want { + t.Errorf("isCrawler(%q) = %v, want %v", ua, got, want) + } + } +} + +func TestIsPrefetch(t *testing.T) { + cases := []struct { + name string + header string + value string + want bool + }{ + {"普通请求", "", "", false}, + {"Chrome 预取", "Sec-Purpose", "prefetch", true}, + {"Chrome 预渲染", "Sec-Purpose", "prefetch;prerender", true}, + {"Safari 预览", "X-Purpose", "preview", true}, + {"Firefox 预取", "X-Moz", "prefetch", true}, + } + for _, c := range cases { + req := httptest.NewRequest("GET", "/_/someone", nil) + if c.header != "" { + req.Header.Set(c.header, c.value) + } + if got := isPrefetch(req); got != c.want { + t.Errorf("%s: isPrefetch = %v, want %v", c.name, got, c.want) + } + } +} diff --git a/internal/insights/recorder.go b/internal/insights/recorder.go new file mode 100644 index 0000000..1ba02e7 --- /dev/null +++ b/internal/insights/recorder.go @@ -0,0 +1,290 @@ +// Copyright 2026 tamakyi. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package insights + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "strings" + "sync" + "time" + + "github.com/sirupsen/logrus" + + "github.com/tamakyi/TamaBox/internal/db" +) + +// Recorder 异步记录箱主页访问。 +// +// 为什么不同步写库:箱主页是最高频的读路径,一次页面渲染里已经有 5~6 次查询 +// (用户、提问列表、回答数、三个功能开关)。再同步加一条 UPSERT,等于给每个 +// 访客多付一次写事务的代价 —— 而统计本来就不需要精确到毫秒,丢掉最后几秒的 +// 数据没有任何影响。所以这里是「尽力而为」:投递不阻塞,积压了就丢。 +// +// 生命周期:Init 时创建,web 进程退出前 Close 一次(flush 残留)。未初始化时 +// 包级变量为 nil,调用方用 RecordView 这个 nil 安全的包装函数。 +type Recorder struct { + store db.BoxViewsStore + secret []byte + + ch chan viewEvent + done chan struct{} + wg sync.WaitGroup + + // 关闭后仍有 handler 在跑(优雅关闭期),此时不能再往已关闭的 channel 发送。 + mu sync.RWMutex + closed bool +} + +type viewEvent struct { + userID uint + at time.Time + visitor string +} + +const ( + // 队列长度:突发流量下攒 512 条足够,超出说明数据库已经跟不上了, + // 此时记录本身也不是最重要的东西。 + recorderQueueSize = 512 + // flush 间隔:2 秒足够让「刷新页面就能看到数字」的体验成立, + // 又能把同一秒内的几十次访问合并成一条 SQL。 + recorderFlushInterval = 2 * time.Second + // 单批上限:一条 INSERT 里塞几百行会拉长事务,分批更稳。 + recorderBatchSize = 200 +) + +// NewRecorder 创建记录器并立刻拉起后台 flush 循环。secret 用于给访客 IP 加盐哈希, +// 通常传 server.xsrf_key。 +// +// 为什么在构造函数里就启动 goroutine,而不是让调用方另起一个 Run: +// 否则 Close 与 Run 之间存在竞态 —— 若进程在 Run 还没被调度时就退出, +// wg.Wait 会立刻返回,队列里最后几秒的访问就丢了。这里把启动收进来, +// wg 计数在构造时就加好,时序是确定的。 +func NewRecorder(store db.BoxViewsStore, secret string) *Recorder { + r := &Recorder{ + store: store, + secret: []byte("boxview:" + secret), + ch: make(chan viewEvent, recorderQueueSize), + done: make(chan struct{}), + } + r.wg.Add(1) + go r.loop() + return r +} + +// loop 后台批量写入,直到 Close。 +func (r *Recorder) loop() { + defer r.wg.Done() + + ticker := time.NewTicker(recorderFlushInterval) + defer ticker.Stop() + + batch := make([]viewEvent, 0, recorderBatchSize) + flush := func() { + if len(batch) == 0 { + return + } + r.write(batch) + batch = batch[:0] + } + + for { + select { + case ev := <-r.ch: + batch = append(batch, ev) + if len(batch) >= recorderBatchSize { + flush() + } + case <-ticker.C: + flush() + case <-r.done: + // 退出前把 channel 里剩下的取干净,否则优雅关闭期间的访问全丢。 + for { + select { + case ev := <-r.ch: + batch = append(batch, ev) + if len(batch) >= recorderBatchSize { + flush() + } + default: + flush() + return + } + } + } + } +} + +// Close 停止接收新事件,flush 残留后返回。幂等,可安全 defer。 +func (r *Recorder) Close() { + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return + } + r.closed = true + close(r.done) + r.mu.Unlock() + + r.wg.Wait() +} + +func (r *Recorder) write(batch []viewEvent) { + if r.store == nil { + return + } + opts := make([]db.RecordBoxViewOptions, 0, len(batch)) + for _, ev := range batch { + opts = append(opts, db.RecordBoxViewOptions{ + UserID: ev.userID, + ViewedOn: ev.at, + Visitor: ev.visitor, + }) + } + + // 用独立 context:请求的 context 在响应写完后就被取消了, + // 拿它写库会得到 "context canceled",统计静默丢失。 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := r.store.RecordBatch(ctx, opts); err != nil { + // 统计写失败不影响任何用户可见行为,只留日志。 + logrus.WithError(err).WithField("count", len(opts)).Warn("写入访问统计失败") + } +} + +// record 非阻塞投递。队列满时直接丢弃:宁可少记几次,也不能让访客的页面 +// 因为统计而变慢。 +func (r *Recorder) record(userID uint, at time.Time, visitor string) { + r.mu.RLock() + defer r.mu.RUnlock() + if r.closed { + return + } + select { + case r.ch <- viewEvent{userID: userID, at: at, visitor: visitor}: + default: + } +} + +// visitorID 计算「当天」的访客标识:HMAC(secret, 日期 || IP)。 +// +// 为什么带日期:不加日期就能长期追踪同一个 IP 的来访轨迹,那已经是一份 +// 可关联的行为记录。加日期盐之后,同一天内的重复访问仍能去重(UV 准确), +// 跨天则完全不可关联 —— 代价是「近 7 天 UV」是 7 个日 UV 之和。 +// +// 取前 16 字节十六进制 = 32 字符,碰撞概率在个人站量级可以忽略。 +func (r *Recorder) visitorID(ip string, at time.Time) string { + mac := hmac.New(sha256.New, r.secret) + mac.Write([]byte(at.Format("2006-01-02"))) + mac.Write([]byte{0}) + mac.Write([]byte(ip)) + return hex.EncodeToString(mac.Sum(nil))[:32] +} + +// Views 全局记录器。未初始化(如单测、CLI)时为 nil。 +var Views *Recorder + +// RecordView 记录一次箱主页访问。参数分别是箱主 ID、客户端 IP、请求。 +// +// 会自行过滤掉:箱主本人、明显的爬虫、预取/预渲染请求。这些都不该算进 +// 「有多少人来看过我的箱」,否则箱主刷自己的页面就能看到漂亮的数字。 +func RecordView(userID uint, ip string, r *http.Request) { + if Views == nil || userID == 0 { + return + } + if isPrefetch(r) || isCrawler(r.UserAgent()) { + return + } + now := time.Now() + Views.record(userID, now, Views.visitorID(ip, now)) +} + +// isPrefetch 判断这是不是浏览器/链接预览的预取请求。 +// 预取会在用户根本没打开页面的情况下命中,算进 UV 会明显虚高。 +func isPrefetch(r *http.Request) bool { + if r == nil { + return false + } + for _, h := range []string{"Sec-Purpose", "X-Purpose", "X-Moz"} { + v := strings.ToLower(r.Header.Get(h)) + if strings.Contains(v, "prefetch") || strings.Contains(v, "prerender") || strings.Contains(v, "preview") { + return true + } + } + return false +} + +// crawlerHints 常见爬虫 UA 片段。 +// +// 只做粗筛,不追求完整:目标是不让搜索引擎与监控探针把 UV 抬高。 +// 误判成本很低(少记一次),漏判成本也低(多记一次),所以用最简单的子串匹配。 +var crawlerHints = []string{ + "bot", "spider", "crawler", "crawl", "slurp", "scrapy", + "curl/", "wget/", "python-requests", "python-urllib", "go-http-client", + "httpclient", "okhttp", "axios", "headlesschrome", "phantomjs", + "facebookexternalhit", "telegrambot", "whatsapp", "discordbot", + "monitor", "uptime", "pingdom", "statuscake", +} + +func isCrawler(ua string) bool { + if ua == "" { + // 空 UA:绝大多数是脚本。真·浏览器一定带 UA。 + return true + } + ua = strings.ToLower(ua) + for _, hint := range crawlerHints { + if strings.Contains(ua, hint) { + return true + } + } + return false +} + +// RetentionDays 访问明细的保留天数。 +// +// 明细表一行代表「某访客某天来过某箱」,体积随「日 UV × 箱数」增长。 +// 面板最长只看 30 天,但「历史最好的一天」要看全期,所以留一年出头: +// 既能让箱主看到长期趋势,又不至于让表无限膨胀。 +const RetentionDays = 400 + +// StartMaintenance 启动每日清理协程。ctx 取消时退出。 +// +// 启动时先跑一次:进程停机期间错过的清理在这里补上,不必等满 24 小时。 +func StartMaintenance(ctx context.Context, store db.BoxViewsStore) { + go func() { + run := func() { + if store == nil { + return + } + c, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + n, err := store.Purge(c, time.Now().AddDate(0, 0, -RetentionDays)) + if err != nil { + logrus.WithError(err).Warn("清理过期访问统计失败") + return + } + if n > 0 { + logrus.WithField("rows", n).Info("已清理过期的访问统计") + } + } + + run() + + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + run() + } + } + }() +} diff --git a/internal/route/route.go b/internal/route/route.go index b982e64..f64a2ee 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -499,6 +499,10 @@ func New() *flamego.Flame { f.Group("/user", func() { f.Get("/questions", user.QuestionList) + // 箱主自己的数据面板:访问量 / 提问趋势 / 处理漏斗。 + // 与公开的 /stats(全站)不同,这里只看当前登录用户自己的箱。 + f.Get("/insights", user.Insights) + // 匿名投票管理(箱主侧) f.Group("/votes", func() { f.Get("", user.VoteList) diff --git a/route/question/page.go b/route/question/page.go index f7f7057..3bf2002 100644 --- a/route/question/page.go +++ b/route/question/page.go @@ -35,6 +35,7 @@ import ( "github.com/tamakyi/TamaBox/internal/form" "github.com/tamakyi/TamaBox/internal/i18n" "github.com/tamakyi/TamaBox/internal/imagecheck" + "github.com/tamakyi/TamaBox/internal/insights" "github.com/tamakyi/TamaBox/internal/mail" "github.com/tamakyi/TamaBox/internal/security/moderation" "github.com/tamakyi/TamaBox/internal/shared" @@ -201,6 +202,15 @@ func Pager(ctx context.Context) { } else { ctx.Data["PageQuestionCursor"] = "" } + + // 访问统计:只有走到这里才是「真的把箱主页渲染出去了」—— + // 上面所有错误分支、密码页分支都已提前 return。 + // + // 箱主自己访问自己的箱不计入:否则箱主每刷新一次就把自己的 UV/PV 拉高一次, + // 面板上的数字就没有任何参考价值。爬虫与预取由 RecordView 内部过滤。 + if !ctx.IsLogged || ctx.User.ID != pageUser.ID { + insights.RecordView(pageUser.ID, shared.ResolveClientIP(ctx.Request().Request), ctx.Request().Request) + } } // conversationWithFirstMessage 用于在列表展示私聊时附带第一条消息 diff --git a/route/user/insights.go b/route/user/insights.go new file mode 100644 index 0000000..e6255fa --- /dev/null +++ b/route/user/insights.go @@ -0,0 +1,31 @@ +// Copyright 2026 tamakyi. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package user + +import ( + "time" + + "github.com/tamakyi/TamaBox/internal/context" + "github.com/tamakyi/TamaBox/internal/i18n" + "github.com/tamakyi/TamaBox/internal/insights" +) + +// Insights 箱主自己的数据面板。 +// +// 与 /stats 的区别:那个是全站公开数据(任何人可看、不含单箱维度); +// 这里只统计当前登录用户自己的箱,且只对本人可见 —— 所以数据里可以出现 +// 「你的箱本周被看了多少次」这种带归属的指标。 +// +// 隐私边界:库里只存「每 访客×天×箱 的访问次数」,访客标识是带日盐的 IP HMAC, +// 没有 IP、没有 UA、没有访问路径。因此面板能回答「多少人来了」, +// 但回答不了「谁来过」——这是刻意的,箱主不需要也不应该拿到提问者与访客的身份。 +func Insights(ctx context.Context) { + page := insights.Build(ctx.Request().Context(), ctx.Lang(), ctx.User.ID, time.Now()) + page.FormatInsights() + + ctx.SetTitle(i18n.T(ctx.Lang(), "insights.title")) + ctx.Data["Insights"] = page + ctx.Success("user/insights") +} diff --git a/templates/user/insights.html b/templates/user/insights.html new file mode 100644 index 0000000..679c151 --- /dev/null +++ b/templates/user/insights.html @@ -0,0 +1,445 @@ +{{template "base/header" .}} +{{ $ins := .Insights }}{{ $lang := .LangCode }} + + +
{{t $lang "insights.lead"}}
+{{t $lang "insights.today"}}
+{{t $lang "insights.week"}}
+{{t $lang "insights.month"}}
+{{t $lang "insights.trendNote"}}
+ +{{t $lang "insights.hourNote"}}
+{{t $lang "insights.funnelNote"}}
+{{t $lang "insights.answerRate"}}
+{{t $lang "insights.privacyNote"}}
+