Files
tamabox/internal/context/context.go
T
tamakyi 0124e392c7 fix(correctness): 三处幂等缺陷、私聊首条事务化、验证码失败不再丢正文
四处都属于「并发/失败路径下状态不一致」,用户侧表现为「点了没反应」「发了没收到」。

1) 删除会话重复回退配额
   MarkDeletedByOwner 是 check-then-act:双击时两个请求都读到 is_deleted_by_owner=false,
   都执行 UPDATE、都执行一次 IncrementQuota(-1)。计数不会变负,但会白吃掉其他会话的额度,
   表现为定量收集提前关闭,箱主重置计数也校准不回来。
   → 改条件更新 + RowsAffected 决定是否回退。

2) 双击产生两条重复追问
   「上一条追问必须先被回复」原来只在 handler 里判断(事务外):两个请求都能通过那次检查,
   随后被 CreateIfBelowLimit 的问题行锁串行化,各自 count 都 < 3 —— 两条相同追问都建成,
   吃掉 2/3 名额,箱主还收两封邮件。
   → 判定挪进事务,新增 ErrFollowUpAwaitingReply;handler 的预检查保留作为快速失败。

3) 双击置顶结果与预期相反
   SetPinned(id, !question.IsPinned) 是 read-modify-write:两个请求都读到 false 都写 true,
   用户点两次期望「置顶后取消」,实际仍是「置顶」。
   → 新增 SetPinnedIfCurrent(current, next),第二次因状态已变而 0 行,语义是「只切换一次」。

4) 私聊首条三步写入无事务
   建会话 → 写首条消息 → 计数是三次独立写入,第二步失败会留下 message_count=0 的空会话,
   箱主列表里能点进去却什么都没有;而 BeforeCreate 已占掉一个 6 字符 token(唯一索引),
   孤儿会话永久占用它。
   → 新增 CreateWithFirstMessage,三步收进一个事务。

5) 验证码失败丢正文
   该分支原来走 SetErrorFlash + Redirect,而其它失败分支都是 SetError + Success 回填表单。
   验证码失败恰恰是最常见的失败(token 过期、网络抖动),结果用户写好的正文整段丢失。
   → 改为同请求渲染并回填;错误文案不再透传 err.Error()(可能含上游原始信息)。

6) 5 处裸类型断言改带 ok(share.go ×3 / context.go ×1 / user/vote.go ×1),
   断言失败时丢弃脏缓存并走重建,而不是 panic 掉整个请求。share.go 的 ttlCache 补 Delete。

验证:内部 DB 测试 3 项(并发删除只回退一次配额 5→4、双击只产生一条追问、
双击只切换一次置顶);verify_round3.py 6/6(验证码失败 200+正文回填+不建提问、
私聊首条 message_count 与实际消息数一致)。
2026-10-29 16:28:34 +08:00

414 lines
14 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 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") {
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
}