Files
tamabox/internal/db/follow_ups.go
T
2026-09-08 10:10:44 +08:00

222 lines
8.2 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 每条提问允许的最大追问次数(防滥用)。
// 提问者被回答后,用 token(或登录账号)继续追问,达到上限后不可再追问。
const MaxFollowUpCount = 3
// 追问相关错误(供 handler 区分处理,避免把业务错误当 500)
var (
// ErrFollowUpLimitReached 追问次数已达上限
ErrFollowUpLimitReached = errors.New("follow up limit reached")
// ErrFollowUpNotFound 追问不存在或不属于该问题
ErrFollowUpNotFound = errors.New("follow up not found")
// ErrFollowUpAlreadyAnswered 追问已被回答(禁止覆盖)
ErrFollowUpAlreadyAnswered = errors.New("follow up already answered")
)
var FollowUps FollowUpsStore
type FollowUpsStore interface {
// CreateIfBelowLimit 原子创建追问:仅当该问题追问次数未达上限时创建。
// 返回 created=false 且 err==ErrFollowUpLimitReached 表示已达上限。
// 通过事务内锁定问题行,串行化同一问题的并发追问,消除 count-then-create 竞态。
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 次 IN 查询,避免 N+1。
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 条件防止越权),
// 与 AnswerByID 不同,此处允许覆盖已回答的追问(供主人修改回复使用)。
// 不存在/不属于该问题返回 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
}