Files
tamabox/internal/db/follow_ups.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

232 lines
8.7 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 db
import (
"context"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
// MaxFollowUpCount 每条提问允许的最大追问次数(防滥用)。
const MaxFollowUpCount = 3
// 追问相关错误,供 handler 区分处理,避免把业务错误当 500。
var (
ErrFollowUpLimitReached = errors.New("follow up limit reached")
ErrFollowUpNotFound = errors.New("follow up not found")
ErrFollowUpAlreadyAnswered = errors.New("follow up already answered")
// ErrFollowUpAwaitingReply 上一条追问尚未被箱主回复,不允许继续追问。
// 由 CreateIfBelowLimit 在事务内判定(见该方法的注释)。
ErrFollowUpAwaitingReply = errors.New("previous follow up awaiting reply")
)
var FollowUps FollowUpsStore
type FollowUpsStore interface {
// CreateIfBelowLimit 原子创建追问:仅当该问题追问次数未达上限时创建;
// 事务内锁定问题行串行化并发追问,消除 count-then-create 竞态。已达上限返回 (false, ErrFollowUpLimitReached)。
CreateIfBelowLimit(ctx context.Context, opts CreateFollowUpOptions) (created bool, err error)
GetByQuestionID(ctx context.Context, questionID uint) ([]*FollowUp, error)
// GetLatestByQuestionID 返回某条提问最新的一条追问;无追问时返回 (nil, nil)。
GetLatestByQuestionID(ctx context.Context, questionID uint) (*FollowUp, error)
// GetByQuestionIDs 批量获取多个提问的追问,按 question_id 分组返回(导出场景合并 N+1 为 1 次 IN 查询)。
GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*FollowUp, error)
CountByQuestionID(ctx context.Context, questionID uint) (int64, error)
// AnswerByID 回答某条追问;questionID 防越权(必须属于该问题),仅允许回答尚未回答的(answer 为空)。
// 已回答返回 ErrFollowUpAlreadyAnswered,不存在/不属于该问题返回 ErrFollowUpNotFound。
AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),允许覆盖已回答的(供主人修改回复)。
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
DeleteByQuestionID(ctx context.Context, questionID uint) error
}
func NewFollowUpsStore(db *gorm.DB) FollowUpsStore {
return &followUps{db}
}
type followUps struct {
*gorm.DB
}
// FollowUp 表示对某条提问的一轮追问。
type FollowUp struct {
dbutil.Model
QuestionID uint `gorm:"index:idx_follow_up_question" json:"question_id"`
Content string `gorm:"type:text" json:"content"`
Answer string `gorm:"type:text" json:"answer"`
}
type CreateFollowUpOptions struct {
QuestionID uint
Content string
}
func (db *followUps) CreateIfBelowLimit(ctx context.Context, opts CreateFollowUpOptions) (bool, error) {
created := false
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 锁定问题行,串行化同一问题的并发追问(MySQL/Postgres 均支持 FOR UPDATE)。
var q Question
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id").
First(&q, opts.QuestionID).Error; err != nil {
return errors.Wrap(err, "lock question for follow up")
}
var count int64
if err := tx.Model(&FollowUp{}).
Where("question_id = ?", opts.QuestionID).
Count(&count).Error; err != nil {
return errors.Wrap(err, "count follow ups by question id")
}
if count >= MaxFollowUpCount {
return ErrFollowUpLimitReached
}
// 「上一条追问必须先被回复」也必须在事务内判断。
//
// 这段校验原来只在 handler 里做(事务外):双击提交时两个请求都读到
// 「最新一条还没回复」,随后被这行 FOR UPDATE 串行化,各自 count 都 < 3,
// 于是两条内容相同的追问都创建成功 —— 吃掉 2/3 名额,箱主还会收两封邮件。
// 行锁只保护「条数」,不保护「业务前置条件」,所以判断必须挪进来。
var latest FollowUp
if err := tx.Where("question_id = ?", opts.QuestionID).
Order("id DESC"). // 用 id 而非 created_at:同毫秒写入时时间排序不稳定
Limit(1).
Find(&latest).Error; err != nil {
return errors.Wrap(err, "get latest follow up for reply check")
}
if latest.ID != 0 && latest.Answer == "" {
return ErrFollowUpAwaitingReply
}
f := &FollowUp{
QuestionID: opts.QuestionID,
Content: opts.Content,
}
if err := tx.Create(f).Error; err != nil {
return errors.Wrap(err, "create follow up")
}
created = true
return nil
})
if err != nil {
return false, err
}
return created, nil
}
// GetByQuestionID 按时间正序返回某条提问的所有追问。
func (db *followUps) GetByQuestionID(ctx context.Context, questionID uint) ([]*FollowUp, error) {
var followUps []*FollowUp
if err := db.WithContext(ctx).
Where("question_id = ?", questionID).
Order("created_at ASC").
Find(&followUps).Error; err != nil {
return nil, errors.Wrap(err, "get follow ups by question id")
}
return followUps, nil
}
// GetLatestByQuestionID 返回某条提问最新的一条追问(created_at 倒序);无追问时返回 (nil, nil),供发布前校验上一条是否已回复。
func (db *followUps) GetLatestByQuestionID(ctx context.Context, questionID uint) (*FollowUp, error) {
var fu FollowUp
err := db.WithContext(ctx).
Where("question_id = ?", questionID).
Order("created_at DESC").
Limit(1).
First(&fu).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, errors.Wrap(err, "get latest follow up by question id")
}
return &fu, nil
}
// GetByQuestionIDs 批量获取多个提问的追问,按 question_id 分组返回(解决导出 N+1)。
func (db *followUps) GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*FollowUp, error) {
result := make(map[uint][]*FollowUp)
if len(questionIDs) == 0 {
return result, nil
}
var items []*FollowUp
if err := db.WithContext(ctx).
Where("question_id IN ?", questionIDs).
Order("question_id ASC, created_at ASC").
Find(&items).Error; err != nil {
return nil, errors.Wrap(err, "get follow ups by question ids")
}
for _, fu := range items {
result[fu.QuestionID] = append(result[fu.QuestionID], fu)
}
return result, nil
}
// CountByQuestionID 统计某条提问的追问次数,用于限制追问上限。
func (db *followUps) CountByQuestionID(ctx context.Context, questionID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).
Model(&FollowUp{}).
Where("question_id = ?", questionID).
Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count follow ups by question id")
}
return count, nil
}
// AnswerByID 更新回答(带 question_id 防越权)。
// 原子条件含 answer=”,已回答的不被覆盖;RowsAffected==0 再查一次以区分"不存在"与"已回答"。
func (db *followUps) AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error {
res := db.WithContext(ctx).
Model(&FollowUp{}).
Where("id = ? AND question_id = ? AND answer = ''", id, questionID).
Update("answer", answer)
if res.Error != nil {
return errors.Wrap(res.Error, "answer follow up")
}
if res.RowsAffected == 0 {
var cnt int64
if err := db.WithContext(ctx).
Model(&FollowUp{}).
Where("id = ? AND question_id = ?", id, questionID).
Count(&cnt).Error; err != nil {
return errors.Wrap(err, "check follow up existence")
}
if cnt == 0 {
return ErrFollowUpNotFound
}
return ErrFollowUpAlreadyAnswered
}
return nil
}
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
// 允许覆盖已回答的追问(用于主人修改回复)。不存在/不属于该问题返回 ErrFollowUpNotFound。
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error {
res := db.WithContext(ctx).
Model(&FollowUp{}).
Where("id = ? AND question_id = ?", id, questionID).
Update("answer", answer)
if res.Error != nil {
return errors.Wrap(res.Error, "update follow up answer")
}
if res.RowsAffected == 0 {
return ErrFollowUpNotFound
}
return nil
}
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
func (db *followUps) DeleteByQuestionID(ctx context.Context, questionID uint) error {
if err := db.WithContext(ctx).Where("question_id = ?", questionID).Delete(&FollowUp{}).Error; err != nil {
return errors.Wrap(err, "delete follow ups by question id")
}
return nil
}