Files
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

415 lines
14 KiB
Go
Raw Permalink 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 context
import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strings"
"github.com/flamego/csrf"
"github.com/flamego/flamego"
"github.com/flamego/session"
"github.com/flamego/template"
"github.com/pkg/errors"
"github.com/unknwon/com"
"github.com/wuhan005/govalid"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/shared"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
"github.com/tamakyi/TamaBox/internal/theme"
)
type EndpointType string
const (
EndpointAPI EndpointType = "api"
EndpointWeb EndpointType = "web"
)
func (e EndpointType) IsAPI() bool {
return e == EndpointAPI
}
func (e EndpointType) IsWeb() bool {
return e == EndpointWeb
}
func APIEndpoint(ctx Context) {
ctx.Map(EndpointAPI)
}
// Context represents context of a request.
type Context struct {
flamego.Context
Data template.Data
Session session.Session
Template template.Template
User *db.User
IsLogged bool
}
// HasError returns true if error occurs in form validation.
func (c *Context) HasError() bool {
hasErr, ok := c.Data["HasError"]
if !ok {
return false
}
// 带 ok 的断言:Data 是 map[string]interface{},任何写入方都可能塞进非 bool 值,
// 裸断言遇到那种情况会直接 panic 掉整个请求。非 bool 一律视为「无错误」。
v, isBool := hasErr.(bool)
return isBool && v
}
func (c *Context) SetError(err error, f ...interface{}) {
// 防御性兜底:调用方可能传入 nil error(如从某个可能返回 nil 的函数直接透传),
// 此时下方 err.Error() 会触发 nil 指针 panic。统一兜底为「未知错误」,避免整页崩溃。
if err == nil {
err = errors.New("发生未知错误")
}
c.Data["HasError"] = true
// 表单校验(govalid)错误:其 Error() 在字段带 msg 标签时为 i18n 键,
// 在此按当前界面语言翻译;其余业务错误(errors.New(i18n.T(...)) 等)原样保留。
if ge, ok := err.(*govalid.ErrContext); ok {
c.Data["Error"] = i18n.T(c.Lang(), ge.Error())
} else {
c.Data["Error"] = err.Error()
}
// Set back the form data.
if len(f) > 0 {
form := f[0]
typ := reflect.TypeOf(form)
val := reflect.ValueOf(form)
if typ.Kind() == reflect.Ptr {
typ = typ.Elem()
val = val.Elem()
}
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
fieldName := com.ToSnakeCase(field.Name)
c.Data[fieldName] = val.Field(i).Interface()
}
}
span := trace.SpanFromContext(c.Request().Context())
if span.IsRecording() {
span.SetAttributes(
attribute.String("tamabox.error", err.Error()),
)
}
}
func (c *Context) SetInternalError(f ...interface{}) {
span := trace.SpanFromContext(c.Request().Context())
traceID := span.SpanContext().TraceID()
c.Data["FlashTip"] = fmt.Sprintf("若问题一直出现,请带上该段字符 %s 提交反馈。", traceID.String())
c.SetError(errors.New(i18n.T(c.Lang(), "internal.errFlash")), f...)
}
// Success renders HTML template with given name with 200 OK status code.
func (c *Context) Success(templateName string) {
c.Template.HTML(http.StatusOK, templateName)
}
func (c *Context) SetTitle(title string) {
c.Data["Title"] = title
}
func (c *Context) Refresh() {
c.Redirect(c.Request().URL.Path)
}
func (c *Context) JSON(data interface{}) error {
// 显式声明内容类型:之前靠 Go 的 sniff 兜底,实测返回的是 text/plain。
// 有 nosniff 所以不会被当 HTML 渲染,但按 Content-Type 分派的客户端
// (fetch 的 .json()、axios、部分 SDK)会因此走错分支。
c.ResponseWriter().Header().Set("Content-Type", "application/json; charset=utf-8")
resp := map[string]interface{}{
"code": 0,
"data": data,
"message": "success",
}
return json.NewEncoder(c.ResponseWriter()).Encode(resp)
}
func (c *Context) ServerError() error {
return c.JSONError(50000, i18n.T(c.Lang(), "internal.errServer"))
}
func (c *Context) JSONError(errorCode int, message string) error {
// 必须在 WriteHeader 之前设置:一旦写出状态码,头就锁定了。
c.ResponseWriter().Header().Set("Content-Type", "application/json; charset=utf-8")
span := trace.SpanFromContext(c.Request().Context())
resp := map[string]interface{}{
"code": errorCode,
"message": message,
"trace_id": span.SpanContext().TraceID().String(),
}
statusCode := errorCode / 100
if statusCode < 100 || statusCode > 999 {
statusCode = http.StatusInternalServerError
}
c.ResponseWriter().WriteHeader(statusCode)
return json.NewEncoder(c.ResponseWriter()).Encode(resp)
}
// Contexter initializes a classic context for a request.
func Contexter() flamego.Handler {
return func(ctx flamego.Context, data template.Data, session session.Session, x csrf.CSRF, t template.Template, flash session.Flash) {
c := Context{
Context: ctx,
Data: data,
Session: session,
Template: t,
}
// POST 类请求统一校验 CSRF(像素追踪代理已改为 GET 且仅转发只读请求,
// 不再需要 CSRF 豁免,故此处不再对 /api/v1/pixel/ 做例外)。
if ctx.Request().Method == http.MethodPost {
x.Validate(ctx)
}
// Get user from session or header when possible
c.User = authenticatedUser(c.Context, c.Session)
var userID uint
if c.User != nil {
c.IsLogged = true
c.Data["IsLogged"] = c.IsLogged
c.Data["LoggedUser"] = c.User
c.Data["LoggedUserID"] = c.User.ID
c.Data["LoggedUserName"] = c.User.Name
userID = c.User.ID
} else {
c.Data["LoggedUserID"] = 0
c.Data["LoggedUserName"] = ""
}
c.Data["IsPixel"] = ctx.Request().URL.Path == "/pixel"
span := trace.SpanFromContext(ctx.Request().Context())
if span.IsRecording() {
span.SetAttributes(
attribute.Bool("tamabox.user.is-login", c.IsLogged),
attribute.Int("tamabox.user.id", int(userID)),
)
}
c.ResponseWriter().Header().Set("Trace-ID", span.SpanContext().TraceID().String())
if flash != nil {
flash, ok := flash.(Flash)
if ok {
switch flash.Type {
case "success":
c.Data["Success"] = flash.Message
case "error":
c.Data["HasError"] = true
c.Data["Error"] = flash.Message
case "info":
c.Data["Info"] = flash.Message
case "warning":
c.Data["Warning"] = flash.Message
}
c.Data["FlashTip"] = flash.FlashTip
}
}
// ---- i18n 语言注入 ----
// 解析优先级(按页面类型):
// - 个人页(/user 后台、/_/{domain} 提问箱):永远跟随主人 profile 语言,忽略访客 cookie。
// /user 后台即登录用户本人的配置,直接采用 c.User.Language;
// /_/{domain} 提问箱页由 question.Pager 中间件按 pageUser.Language 覆盖(见 route/question/page.go)。
// - 公开页(首页/探索/登录等):访客 lang cookie 优先;未设置 cookie 时登录用户资料语言次之;
// 再回退站点默认语言(shared.App)> i18n.Default。
path := ctx.Request().URL.Path
isUserPage := strings.HasPrefix(path, "/user") || strings.HasPrefix(path, "/_/")
c.Data["IsUserPage"] = isUserPage
// 页面作用域:让同一套主题能在不同页面摆不同装饰(箱主页/问题页/设置页…)。
// 模板通过 <html data-page-scope> 输出,主题 css 用
// html[data-theme-style="x"][data-page-scope="y"] 覆盖 --tb-deco 等令牌。
// 未知路径为空串,此时只应用全局装饰,行为与引入该机制前完全一致。
c.Data["PageScope"] = pageScope(path)
// 主题:/user 后台是箱主本人的页面,跟随本人配置;
// /_/{domain} 的箱主主题由 question.Pager 覆盖(访客看到的是箱主的选择)。
// 空串(未选且站点无默认主题)= 不加载主题 css。
if isThemeScope(path) && c.IsLogged {
c.Data["ThemeSlug"] = theme.Resolve(c.User.Theme)
}
// SEO:仅公开页(首页/注册/登录/explore)允许被搜索引擎收录;
// 提问内容(提问箱页、单题页)、密码页、私信、用户后台等隐私页一律 noindex。
isPublicPage := path == "/" || path == "/register" || path == "/login" || strings.HasPrefix(path, "/explore")
if !isPublicPage {
c.Data["NoIndex"] = true
}
lang := ""
if isUserPage {
// 个人页:永远跟随 profile,忽略 cookie(/user 直接采用本人配置;
// /_/{domain} 由 question.Pager 覆盖,此处留空交由其处理)。
if strings.HasPrefix(path, "/user") && c.IsLogged && c.User.Language != "" {
lang = i18n.Resolve(c.User.Language)
}
} else {
// 公开页:会话内显式选择的语言优先,其次登录用户资料语言,最后回退站点默认。
// 语言偏好存于 session(由 session 中间件托管 cookie);此前独立 lang cookie 会被
// session 中间件的 Set-Cookie 整体覆盖而失效,故此处改读 session。
if v := c.Session.Get("lang"); v != nil {
if s, ok := v.(string); ok && s != "" {
lang = i18n.Resolve(s)
}
}
if lang == "" && c.IsLogged && c.User.Language != "" {
lang = i18n.Resolve(c.User.Language)
}
}
if lang == "" {
lang = shared.App.Lang()
}
c.Data["Lang"] = lang
c.Data["LangCode"] = lang
c.Data["SupportedLangs"] = i18n.Supported()
c.SetTitle(i18n.T(lang, "site.title"))
c.Data["CSRFToken"] = x.Token()
c.Data["CSRFTokenHTML"] = templatepkg.Safe(`<input type="hidden" name="_csrf" value="` + x.Token() + `">`)
c.Data["RecaptchaDomain"] = conf.Recaptcha.Domain
c.Data["RecaptchaSiteKey"] = conf.Recaptcha.SiteKey
c.Data["RecaptchaTurnstileStyle"] = conf.Recaptcha.TurnstileStyle
c.Data["CaptchaType"] = conf.Recaptcha.CaptchaType
c.Data["CurrentURI"] = ctx.Request().Request.RequestURI
c.Data["ExternalURL"] = conf.App.ExternalURL
// 静态资源版本号:base.css 与主题 css 共用同一个值,改样式只需改 conf.StaticVersion。
c.Data["StaticVersion"] = conf.StaticVersion
// 默认头像/背景图兜底:未设置自定义头像的箱主,其二维码 logo 应与页面头像一致回退到 default_avatar。
// 模板按原始 URL 直连(easy.qrcode 以 crossOrigin:'anonymous' 加载,配合对象存储桶 CORS
// 返回 Access-Control-Allow-Origin,保证 canvas 不被污染、可正常导出)。
c.Data["DefaultAvatar"] = conf.Upload.DefaultAvatarURL
c.Data["DefaultBackgroundImage"] = conf.Upload.DefaultBackgroundImageURL
// [limits] 段全站上限的模板同源入口:所有页面可直接引用
// {{.Limits.QuestionContentLen}} / {{.Limits.AnswerContentLen}} /
// {{.Limits.FollowUpContentLen}} / {{.Limits.ChatMessageLen}} 等,
// 作为输入框 maxlength 等与后端校验的同一数据源,避免各处写死数字漂移。
c.Data["Limits"] = conf.Limits
// ⚠️ VConsole can only be enabled for the first user for security reasons.
c.Data["VConsole"] = ctx.Query("debug") == "on" && c.IsLogged && c.User.ID == 1
// 🚨 SECURITY: Prevent MIME type sniffing in some browsers,
c.ResponseWriter().Header().Set("X-Content-Type-Options", "nosniff")
c.ResponseWriter().Header().Set("X-Frame-Options", "DENY")
ctx.Map(c)
ctx.Map(EndpointWeb)
if conf.App.MaintenanceMode {
c.ResponseWriter().Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Success("maintenance-mode")
return
}
}
}
// Lang 返回当前请求解析出的界面语言代码(已规范化,必定是字典可查的规范代码)。
// 解析优先级见上方 Data["Lang"] 注入逻辑(个人页跟随箱主语言;公开页跟随 lang cookie /
// 登录用户资料语言 / 站点默认语言)。若中间件尚未注入(极少数非标准路径),回退 i18n.Default。
// 用于后端 flash / 错误信息按访客当前语言本地化,避免硬编码中文绕过 i18n。
func (c *Context) Lang() string {
if l, ok := c.Data["Lang"].(string); ok && l != "" {
return l
}
return i18n.Default
}
// isThemeScope 判断该路径的主题是否由「当前登录用户自己的配置」决定。
// 只覆盖 /user 后台(本人页面);提问箱相关页面由 question.Pager 按箱主注入,
// 公开页(首页/探索/登录)保持站点默认外观,不跟随登录状态变化。
func isThemeScope(path string) bool {
return path == "/user" || strings.HasPrefix(path, "/user/")
}
// pageScope 把请求路径归类成「主题页面作用域」。
//
// 目的:同一个主题在不同页面可以摆不同装饰(箱主页放人物、问题页只留光、设置页干脆不放图),
// 而不必为每种页面复制一整套主题。模板把它输出到 <html data-page-scope="...">,
// 主题 css 用 html[data-theme-style="x"][data-page-scope="y"] 覆盖 --tb-deco 等令牌。
//
// 返回空串表示「不在主题作用域内」(首页/探索/登录等公开页也可返回具体值,
// 只是这些页面通常不带 ThemeSlug,覆盖块不会生效)。
func pageScope(path string) string {
switch {
case path == "/":
return "home"
case path == "/explore" || strings.HasPrefix(path, "/explore/"):
return "explore"
case path == "/login" || strings.HasPrefix(path, "/login/"),
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") ||
strings.HasPrefix(path, "/user/insights") {
return "admin"
}
return "profile"
case strings.HasPrefix(path, "/_/"):
// /_/{domain} → 箱主页(问题列表)
// /_/{domain}/{qid} → 单个问题
// /_/{domain}/tag/xxx → 仍是箱主页
rest := strings.Trim(path[len("/_/"):], "/")
parts := strings.Split(rest, "/")
if len(parts) == 2 && isAllDigits(parts[1]) {
return "question"
}
return "box"
case path == "/vote" || strings.HasPrefix(path, "/vote/"):
return "vote"
}
return ""
}
func isAllDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}