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

246 lines
9.3 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 条件防越权),
// 允许覆盖已回答的(供主人修改回复);返回 wasFirst 表示这是首次回答(此前为空)。
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (wasFirst bool, err 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) (bool, error) {
var wasFirst bool
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 先读旧值判断「是否首次回答」。handler 里拿不到可靠旧值:
// 它手上的 question 是请求开始时读的,并发下可能已经过期。
var fu FollowUp
if err := tx.Where("id = ? AND question_id = ?", id, questionID).First(&fu).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrFollowUpNotFound
}
return errors.Wrap(err, "get follow up")
}
wasFirst = fu.Answer == ""
if err := tx.Model(&FollowUp{}).
Where("id = ? AND question_id = ?", id, questionID).
Update("answer", answer).Error; err != nil {
return errors.Wrap(err, "update follow up answer")
}
return nil
})
if err != nil {
return false, err
}
return wasFirst, 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
}