四类问题都出在「多次独立写入 + 失败只打日志」这个模式上。
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。
246 lines
9.3 KiB
Go
246 lines
9.3 KiB
Go
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
|
||
}
|