三处小修 + 两个格式清理,均来自全盘复查: 1. ask-status domain 枚举:ErrUserNotExists 分支原返回空数组、正常分支返回带元素 数组,凭空/非空可枚举 domain 是否存在(含被 L1 隐藏或设了页面密码的半公开箱)。 改为箱主不存在时也回显 ok:false,四种失败响应完全同形。 2. 私聊 SendMessage 写路径用 FeatureStateOf(fail-open 变体):读配置失败会回退默认值, 与「写路径必须 FeatureStateOfStrict(失败即拒)」的约定不符。此前靠 chat 默认关闭 的巧合侥幸安全,现统一改 Strict。 3. 图片压缩 submit 兜底可重复提交:capture 监听器里 tbBeginSubmit 从 Promise 回调提前 到 preventDefault 同步栈,物理阻止压缩窗口内双击重复提交。 顺带:box_views.go / message_image.go / insights 两个文件补末尾换行与 struct 字段对齐 (gofmt 干净)。
439 lines
12 KiB
Go
439 lines
12 KiB
Go
// 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)
|
|
}
|