Files
tamabox/route/question/follow_up.go
T
tamakyi d5d25c804b fix(data): 删除改成一个事务且真删、配额与内容同事务
四类问题都出在「多次独立写入 + 失败只打日志」这个模式上。

1) 删除投票的级联没有事务(route/user/vote.go)
   选项 / 表单字段 / 提交记录 / 投票本体是 4 次独立写入,前 3 步失败只 Warn。
   中途失败会留下「选项没了但票还在」这种界面无法修复的状态 —— 重试删除时
   投票已不存在,只能报 not exist,脏数据永远留着。
   → db.Votes.DeleteWithCascade:四张表在同一事务里删完。

2) 删除其实是软删除,个人数据没真删掉
   模型都嵌了 dbutil.Model(含 DeletedAt),原来所有 Delete 都是软删:
   提交记录里参与者填写的文本、voter_ip、voter_token,以及提问里的 FromIP,
   只是「界面上看不见」,库里永久留着。用户点删除的预期不是这样。
   软删还会占住 link_token 唯一索引,删过的链接永远不能复用。
   → 删除投票与删除提问统一 Unscoped 真删;提问的追问/图片关联/配额回退
     原来也是三个独立写入(失败只 Warn),一并收进事务。

3) 配额计数会永久偏低
   「创建内容」与「quota_count+1」是两次写入,后者失败只 Warn:
   箱主看到的「已收集 7/10」偏小,达到上限后因为计数偏小实际还能继续收,
   等于定量收集被静默绕过;重置计数也校准不回来(重置是把计数清零,更脱节)。
   → 抽出 incrementQuotaTx(tx, userID, feature, delta) 作为唯一实现,
     提问 Questions.Create、私聊 CreateWithFirstMessage、投票提交
     VoteSubmissions.Create(新增 QuotaUserID)都在自己的事务里完成 +1。
   注意口径:投票的定量收集是「累计收到的票数」(提交成功时 +1),
   不是「创建了几个投票」—— 所以删除投票不回退这个计数。

4) 改写追问回答每次都发通知邮件
   箱主改措辞、补一句、修错别字都会重新保存,每次都发的话提问者会连着收到
   好几封一模一样的「您的提问有回复」。主回答那边早有 question.Answer == ""
   的判定,追问这里补齐:UpdateAnswerByID 在事务内读旧值返回 wasFirst。

顺带:
- isQuestioner 的 token 比较从 == 改为 subtle.ConstantTimeCompare,
  与 Pager 中间件里的校验同口径(== 按字节短路,可用响应时间逐字节试探)。
- CSV 导出的表头(字段名)与选项内容列补 sanitizeCSVCell。原来只有数据列走它,
  而选项名同样可能是 =HYPERLINK(...) 形式;判断也改成看 trim 后的首字符。
- 写路径的功能开关改 fail-closed:新增 db.FeatureStateOfStrict,读配置失败时
  拒绝而不是回退默认配置 —— 提问/投票的默认值是「开启」,fail-open 等于让一次
  数据库抖动绕过箱主设的关闭与定量上限。读展示仍用宽松版。

验证:全量单测(带 DB)与 go build ./... 通过;verify_round3 6/6(私聊
message_count 与实际消息数一致)、verify_p1 4/4、verify_round2 19/19、
verify_page_next 6/6、verify_chat_owner 3/3。
2026-10-29 17:18:28 +08:00

230 lines
9.5 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.
package question
import (
stdctx "context"
"crypto/subtle"
"fmt"
"unicode/utf8"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
)
// isQuestioner 判断当前请求者是否为该提问的提问者。
// 匿名提问者通过 token(URL 或表单隐藏域)验证;登录提问者通过账号 ID 验证。
func isQuestioner(ctx context.Context, question *db.Question, token string) bool {
// 常数时间比较:与 Pager 中间件里的校验(route/question/question.go)同一口径。
// 用 == 会按字节短路,攻击者能靠响应时间逐字节试探出 token ——
// 而 token 就是「私密提问 / 尚未回答的提问」唯一的钥匙。
if question.Token != "" && token != "" &&
subtle.ConstantTimeCompare([]byte(question.Token), []byte(token)) == 1 {
return true
}
if ctx.IsLogged && question.AskerUserID != 0 && ctx.User.ID == question.AskerUserID {
return true
}
return false
}
// PublishFollowUp 提问者在问题被回答后,用 token 继续追问。
func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Question, f form.PublishFollowUpQuestion) {
// govalid 校验失败(追问内容为空)时回显表单,与 AnswerFollowUp 同路径。
// (旧实现漏掉该早退:required 失败后仍会继续 censor/入库,追问空内容也会被创建。)
if ctx.HasError() {
ctx.Success("question/item")
return
}
// 追问内容长度上限(conf.Limits.FollowUpContentLen,[limits] 配置段),
// 与前端追问框 maxlength 同源,rune 计数。
if utf8.RuneCountInString(f.Content) > conf.Limits.FollowUpContentLen {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "form.followup.contentTooLong", conf.Limits.FollowUpContentLen)), f)
ctx.Success("question/item")
return
}
token := ctx.Query("t")
if token == "" {
token = f.Token
}
if !isQuestioner(ctx, question, token) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errNoPermission"))
ctx.Redirect("/")
return
}
// 只能对已回答的问题追问
if question.Answer == "" {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errNotAnswered"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
// 追问次数限制:先快速预检查(友好提示),CreateIfBelowLimit 内再原子兜底
// (并发场景下 count-then-create 存在竞态,必须以数据库原子检查为准)。
count, err := db.FollowUps.CountByQuestionID(ctx.Request().Context(), question.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to count follow ups")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
if count >= db.MaxFollowUpCount {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errLimit", db.MaxFollowUpCount))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
// 服务端校验"等主人回复":模板仅在前一条被回复后才展示追问表单,
// 但 POST 可被直接构造绕过,因此必须检查最新一条追问是否已回复。
//
// 这一段只是「快速失败」省掉一次事务开销;并发/双击下的正确性由
// db.FollowUps.CreateIfBelowLimit 在事务内重新判定(见 ErrFollowUpAwaitingReply)。
latest, err := db.FollowUps.GetLatestByQuestionID(ctx.Request().Context(), question.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get latest follow up")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
if latest != nil && latest.Answer == "" {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errWaitReply"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
if ctx.HasError() {
ctx.Success("question/item")
return
}
// 内容安全检测:统一守卫,按箱主「审核严格度」裁决是否放行
// (提问 / 投票 / 私聊共用同一份判定,见 internal/security/moderation)。
if _, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), pageUser.ID, pageUser.ModerationStrictness), ctx.Lang(), f.Content); err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
created, err := db.FollowUps.CreateIfBelowLimit(ctx.Request().Context(), db.CreateFollowUpOptions{
QuestionID: question.ID,
Content: f.Content,
})
if err != nil {
if errors.Is(err, db.ErrFollowUpLimitReached) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errLimit", db.MaxFollowUpCount))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
if errors.Is(err, db.ErrFollowUpAwaitingReply) {
// 事务层的兜底(并发双击时会走到这里)
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errWaitReply"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create follow up")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
_ = created
// 邮件通知提问箱主人(开启邮件提醒时)
shared.SafeGo(func() {
if pageUser.Notify == db.NotifyTypeEmail {
if err := mail.SendNewFollowUpMail(pageUser.Email, pageUser.Domain, question.ID, question.Content, f.Content, question.Token); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new follow up mail to user")
}
}
})
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "followup.flash.sent"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
}
// AnswerFollowUp 提问箱主人回答某条追问。
func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Question, f form.AnswerFollowUpQuestion) {
if ctx.User.ID != pageUser.ID {
ctx.Redirect("/")
return
}
if ctx.HasError() {
ctx.Success("question/item")
return
}
// 追问的回答内容长度上限:与主回答共用 conf.Limits.AnswerContentLen
// ([limits] 配置段),与前端回复框 maxlength 同源,rune 计数。
if utf8.RuneCountInString(f.Answer) > conf.Limits.AnswerContentLen {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "form.followup.answerTooLong", conf.Limits.AnswerContentLen)), f)
ctx.Success("question/item")
return
}
// 站方处置 L2 起拒绝回答追问:追问回答同样是公开内容,且没有「私密」这一档可以降级。
restrictionLevel, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), pageUser.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
if db.RestrictionAtLeast(restrictionLevel, db.RestrictionL2) {
// 同请求渲染,用 SetError(SetErrorFlash 要等下一次请求才显示)。
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errRestrictedPublish")), f)
ctx.Success("question/item")
return
}
followUpID := uint(ctx.ParamInt("followUpID"))
// 内容安全检测:统一守卫,箱主本人回复同样按自己的审核严格度裁决。
if _, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictnessFor(restrictionLevel, pageUser.ModerationStrictness), ctx.Lang(), f.Answer); err != nil {
// 同请求渲染,用 SetError;原先的 SetErrorFlash 在这里不会显示出来(既有缺陷)。
ctx.SetError(err, f)
ctx.Success("question/item")
return
}
wasFirst, err := db.FollowUps.UpdateAnswerByID(ctx.Request().Context(), question.ID, followUpID, f.Answer)
if err != nil {
if errors.Is(err, db.ErrFollowUpNotFound) {
// 同请求渲染需用 SetError(SetErrorFlash 要等下一次请求才显示)。
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "followup.errNotFound")), f)
ctx.Success("question/item")
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to answer follow up")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
// 邮件通知提问者(留下邮箱时):复用"您的提问有了回复"模板,带 token 链接。
//
// 只在**首次**回答时发:箱主改一次措辞、补一句、修错别字都会重新保存,
// 每次都发的话提问者会连着收到好几封一模一样的「您的提问有回复」。
// 主回答那边早就有同样的判定(question.Answer == ""),这里补齐。
shared.SafeGo(func() {
if wasFirst && question.ReceiveReplyEmail != "" {
if err := mail.SendNewAnswerMail(question.ReceiveReplyEmail, pageUser.Domain, question.ID, question.Content, f.Answer, question.Token); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send follow up answer mail to questioner")
}
}
})
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "followup.flash.answered"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID))
}