212 lines
7.6 KiB
Go
212 lines
7.6 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")
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|