Files
tamakyi 87176516f3 fix(data): 删除提问时追问与图片一并真删,不再软删残留
questions.DeleteByID 对主表用 Unscoped 真删,但级联清理的两个子 store 仍用软删:
FollowUp(内嵌 dbutil.Model)与 UploadImage(内嵌 gorm.Model)都带 DeletedAt,
裸 Delete 是软删。于是提问正文(含 FromIP、邮箱)删干净了,追问文本与图片元数据
(name/md5/key)却带着 deleted_at 永久残留,孤儿行的 question_id 指向已不存在的提问
—— 正是「真删以免个人数据删不干净」想消灭的问题,却在子表漏改。

两个 DeleteByQuestionID 改为 Unscoped 真删。关联表 UploadImageQuestion 无 DeletedAt,
本来就真删,无需 Unscoped(已注释)。

新增 question_delete_hard_test.go:用 Unscoped 计数断言删除后追问/图片/关联行物理上=0,
并做变异验证(改回软删 → 测试失败)证明测试真的能抓住这个回归。
2026-10-29 18:52:19 +08:00

250 lines
9.6 KiB
Go
Raw Permalink 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 删除某条提问时级联清理其全部追问。
//
// 用 Unscoped 真删:追问正文是提问者的个人内容,随提问一并真删(与 questions.DeleteByID
// 对主表的真删口径一致)。软删会留下带 deleted_at 的追问文本,且这些孤儿行的
// question_id 指向一个已不存在的提问 —— 与「删除即真删、个人数据不残留」的意图相悖。
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).Unscoped().Where("question_id = ?", questionID).Delete(&FollowUp{}).Error; err != nil {
return errors.Wrap(err, "delete follow ups by question id")
}
return nil
}