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 }