Files
tamabox/internal/cmd/web.go
T
tamakyi 4e574ac64a feat(insights): 箱主数据面板——访问量、提问趋势、处理漏斗
新增 /user/insights,让箱主第一次能看到「有多少人来过我的箱、提问处理得怎么样」。

数据来源:
- 新表 box_views:一行 = 一访客×一天×一箱,views 累加,唯一索引
  (user_id, viewed_on, visitor)。刻意不内嵌 dbutil.Model —— 软删除 + 唯一索引
  会让被删行永久占槽,且删过的槽位再也插不进去。
- 访客标识 = HMAC(日盐, IP) 前 32 hex:同日可去重(UV 准)、跨日不可关联(隐私)。
  代价是近 7/30 天 UV = 各日 UV 之和,页面上已写明口径。

写入是异步的:buffered channel 512 + 2s 批量 flush,队列满即丢(统计尽力而为,
不该因为记一笔访问就拖慢页面)。过滤箱主本人、空 UA、爬虫 UA、预取请求。
密钥复用 server.xsrf_key;每日清理 400 天前的明细。

页面(纯 CSS 画图,不引图表库):
- 今日/近 7 天/近 30 天 KPI + 上周对比
- 30 天 PV/UV 双柱趋势、24 小时时段分布
- 提问处理漏斗 + 回答率进度条 + 自动结论

聚合层 internal/db/box_analytics.go 复用 analyticsDialectFor 的时区处理,
与全站 stats 分开(那个不含单箱维度、按自然日缓存)。

测试:box_views 4 项(含「同批同键先内存合并」——PG 的 ON CONFLICT 不允许
同条 INSERT 二次命中同一行)、insights 的 pct/窗口切分/visitorID/爬虫过滤。
2026-10-29 18:25:53 +08:00

231 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 cmd
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/uptrace/opentelemetry-go-extra/otellogrus"
"github.com/uptrace/uptrace-go/uptrace"
"github.com/urfave/cli/v2"
"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"
"github.com/tamakyi/TamaBox/internal/stats"
"github.com/tamakyi/TamaBox/internal/tracing"
"github.com/tamakyi/TamaBox/route/question"
)
var Web = &cli.Command{
Name: "web",
Usage: "Start web server",
Action: runWeb,
}
func runWeb(ctx *cli.Context) error {
if err := conf.Init(); err != nil {
return errors.Wrap(err, "load configuration")
}
// 解析可信代理网段(用于客户端 IP 解析,避免维护 Cloudflare 庞大 CIDR 清单)。
if err := shared.InitTrustedProxies(); err != nil {
return errors.Wrap(err, "init trusted proxies")
}
if conf.App.UptraceDSN != "" {
uptrace.ConfigureOpentelemetry(
uptrace.WithDSN(conf.App.UptraceDSN),
uptrace.WithServiceName("tamabox"),
uptrace.WithServiceVersion(conf.BuildCommit),
)
logrus.WithContext(ctx.Context).Debug("Tracing enabled.")
}
logrus.AddHook(otellogrus.NewHook(otellogrus.WithLevels(
logrus.PanicLevel,
logrus.FatalLevel,
logrus.ErrorLevel,
logrus.WarnLevel,
)))
dbType := conf.Database.Type
var dsn string
switch dbType {
case "mysql", "":
dsn = conf.MySQLDsn()
case "postgres":
dsn = conf.PostgresDsn()
default:
return errors.Errorf("unknown database type: %q", dbType)
}
conf.Database.DSN = dsn
gdb, err := db.Init(dbType, dsn)
if err != nil {
return errors.Wrap(err, "connect to database")
}
// 退出前把连接池关掉,避免优雅关闭期间连接还在池里挂着。
if sqlDB, dbErr := gdb.DB(); dbErr == nil {
defer func() {
if err := sqlDB.Close(); err != nil {
logrus.WithError(err).Warn("关闭数据库连接池失败")
}
}()
}
// 蜜罐命中记录保留期清理:honeypot_hits 是只追加的审计表,写入完全由
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
// 保留期复用 honeypot_retain_days(与 CLI prune 同一个口径),开关为 honeypot_auto_clean。
//
// 凭据文件(明文账号密码)也按同一个保留期清理 —— 它们不该比元数据活得更久。
// 这里独立打开一次目录即可:PurgeBefore 只删「整天都早于保留期」的文件,
// 与 route 层那个 Append 用的句柄不会争抢同一天的文件。
stopRetention := make(chan struct{})
if conf.Security.HoneypotAutoClean {
var credPurge db.CredPurgeable
if cs, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir); err != nil {
logrus.WithError(err).Warn("蜜罐凭据目录不可用,凭据文件将不会被自动清理")
} else {
credPurge = cs
}
db.NewHoneypotRetention(db.Honeypots, credPurge, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
defer close(stopRetention)
}
// Redis 配了就必须连得上:session 建在它上面,宕机等于全站 500(含匿名访客)。
// 启动期 fail fast,别让运维在「站点全 500」的现场去猜是不是 Redis。
if conf.Redis.Addr != "" {
if err := shared.PingRedis(conf.Redis.Addr, conf.Redis.Password); err != nil {
return errors.Wrapf(err,
"无法连接 Redis(%s)。session 依赖它,连不上会导致全站 500,启动已中止。"+
"请检查 [redis] addr/password,或清空 addr 改用进程内 session 与缓存",
conf.Redis.Addr)
}
logrus.WithField("addr", conf.Redis.Addr).Info("Redis 连接正常")
} else {
logrus.Info("未配置 [redis] addr:session 与缓存均使用进程内实现(单副本部署可用)")
}
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)
// 分享卡字体:缺字体只会在「第一张分享图被请求」时暴露成 500,而 /share-card 是公开端点。
// 启动时先探一次(顺带把读文件 + 解析的开销挪出请求路径),缺了直接 WARN 并给出补救办法。
if fontPath, err := question.CheckShareCardFont(); err != nil {
logrus.WithError(err).Warn("分享卡字体不可用,/share-card 将返回 500:" +
"装一个 glyf 轮廓的 CJK 字体(apt install fonts-wqy-zenhei)" +
"或把 ttf/otf/ttc 放进 static/fonts/,也可用 [app] font_path 指定")
} else {
logrus.WithField("font", fontPath).Info("分享卡字体已就绪")
}
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 防止慢速客户端占用连接(slowloris 的防线在这里)
// - ReadTimeout / WriteTimeout 防止读写挂死
// - IdleTimeout 复用 keep-alive 连接
// listen 留空时回退到 ":port"(监听所有网卡);Cloudflare Tunnel 部署建议设为
// "127.0.0.1:<port>",使源站端口仅对本地 cloudflared 可见,即使防火墙误配也无法被外部直连。
//
// ReadTimeout 从 30s 提到 120s:站内允许单图 8MB(storage.MaxUploadBytes),
// 请求体上限 12MB(form.MaxRequestBodySize)。30s 传完 8MB 需要 ≥273 KB/s 上行,
// 而手机 4G 上行常在 1–2 Mbps(125–250 KB/s)—— 一张 8MB 图必然超时,且用户
// 已经写好的提问正文会一起丢掉(POST 失败不回填)。120s 对应约 68 KB/s,
// 偏远地区 3G 也能过。
//
// 放宽 ReadTimeout 不会让 slowloris 重新变得可行:那类攻击卡在「发不完 header」,
// 由 10s 的 ReadHeaderTimeout 拦住;ReadTimeout 只影响读完 header 之后传 body 的时长。
addr := conf.Server.Listen
if addr == "" {
addr = fmt.Sprintf(":%d", conf.Server.Port)
}
server := &http.Server{
Addr: addr,
Handler: route.GzipHandler(r),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 120 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
logrus.WithContext(ctx.Context).WithField("port", conf.Server.Port).Info("Listening on")
// 优雅关闭:收到 SIGTERM / SIGINT 后停止接受新连接,给在途请求最多 15 秒收尾。
//
// 改动前是直接 ListenAndServe:Go 对 SIGTERM 的处理就是立即退出进程,
// 于是每次部署 / 重启都会把正在写库的 POST 就地处死,留下
// 「提问已 INSERT、图片还没传完」「回答已写入、配额还没扣」这类半成品。
// systemd Restart=always 与 docker restart 都救不了这个。
runCtx, stop := signal.NotifyContext(ctx.Context, syscall.SIGTERM, os.Interrupt)
defer stop()
serveErr := make(chan error, 1)
go func() {
err := server.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
// 正常的 Shutdown 路径,不算失败
err = nil
}
serveErr <- err
}()
select {
case err := <-serveErr:
// 端口被占 / 监听失败:没有在途请求要收尾,直接返回。
if err != nil {
return errors.Wrap(err, "start http server")
}
return nil
case <-runCtx.Done():
logrus.Info("收到退出信号,正在优雅关闭(最多 15 秒)")
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logrus.WithError(err).Warn("优雅关闭超时,强制断开剩余连接")
_ = server.Close()
}
logrus.Info("HTTP 服务已关闭")
return nil
}