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, 并做变异验证(改回软删 → 测试失败)证明测试真的能抓住这个回归。
This commit is contained in:
@@ -42,7 +42,11 @@ type FollowUpsStore interface {
|
||||
// 允许覆盖已回答的(供主人修改回复);返回 wasFirst 表示这是首次回答(此前为空)。
|
||||
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (wasFirst bool, err error)
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部追问。
|
||||
//
|
||||
// 用 Unscoped 真删:追问正文是提问者的个人内容,随提问一并真删(与 questions.DeleteByID
|
||||
// 对主表的真删口径一致)。软删会留下带 deleted_at 的追问文本,且这些孤儿行的
|
||||
// question_id 指向一个已不存在的提问 —— 与「删除即真删、个人数据不残留」的意图相悖。
|
||||
DeleteByQuestionID(ctx context.Context, questionID uint) error
|
||||
}
|
||||
|
||||
@@ -236,9 +240,9 @@ func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id u
|
||||
return wasFirst, nil
|
||||
}
|
||||
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
|
||||
// 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 {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestQuestionDeleteCascadesHardDelete 验证删除提问时,追问与上传图片也被**真删**,
|
||||
// 而不是软删残留。
|
||||
//
|
||||
// 背景:questions.DeleteByID 对主表用 Unscoped 真删,但历史上一度只有主表真删、
|
||||
// 追问/图片仍软删,导致提问正文删干净了、追问文本与图片元数据却带着 deleted_at
|
||||
// 永久留在库里(这正是「真删以免个人数据删不干净」想消灭的问题,却在子表漏改)。
|
||||
// 本测试用 Unscoped 计数兜底:软删的行在普通查询里不可见,只有 Unscoped 才能看到,
|
||||
// 因此断言「删除后 Unscoped 计数 = 0」才能证明是真删。
|
||||
func TestQuestionDeleteCascadesHardDelete(t *testing.T) {
|
||||
db, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
questions := NewQuestionsStore(db)
|
||||
followUps := NewFollowUpsStore(db)
|
||||
uploadImages := NewUploadImagesStore(db)
|
||||
|
||||
t.Cleanup(func() {
|
||||
// 三张表都要清:follow_ups / upload_images(含链接表)在删除后应该已经空了,
|
||||
// 但其他子测试的 data 可能残留,用 cleanup 统一兜底。
|
||||
_ = cleanup("questions", "follow_ups", "upload_images", "upload_image_questions")
|
||||
})
|
||||
|
||||
// 建一条提问
|
||||
q, err := questions.Create(ctx, CreateQuestionOptions{
|
||||
FromIP: "114.5.1.4",
|
||||
UserID: 1,
|
||||
Content: "需要被彻底删除的提问",
|
||||
AskerUserID: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 给它加一条追问
|
||||
created, err := followUps.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
|
||||
QuestionID: q.ID,
|
||||
Content: "追问内容,也应被真删",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, created)
|
||||
|
||||
// 给它加一张上传图片
|
||||
img, err := uploadImages.Create(ctx, CreateUploadImageOptions{
|
||||
Type: UploadImageQuestionTypeAsk,
|
||||
QuestionID: q.ID,
|
||||
UploaderUserID: 1,
|
||||
Name: "secret.png",
|
||||
FileSize: 1024,
|
||||
Md5: "deadbeef",
|
||||
Key: "2026/10/29/secret.png",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, img.ID)
|
||||
|
||||
// 删除提问
|
||||
require.NoError(t, questions.DeleteByID(ctx, q.ID))
|
||||
|
||||
// 主表:真删
|
||||
require.Equal(t, int64(0), hardCount(t, db, &Question{}, "id = ?", q.ID))
|
||||
|
||||
// 追问:真删(软删的话这里会是 1)
|
||||
require.Equal(t, int64(0), hardCount(t, db, &FollowUp{}, "question_id = ?", q.ID))
|
||||
|
||||
// 上传图片主体:真删
|
||||
require.Equal(t, int64(0), hardCount(t, db, &UploadImage{}, "id = ?", img.ID))
|
||||
|
||||
// 关联行:真删
|
||||
require.Equal(t, int64(0), hardCount(t, db, &UploadImageQuestion{}, "question_id = ?", q.ID))
|
||||
}
|
||||
|
||||
// hardCount 用 Unscoped 统计满足条件的行数,能穿透软删除过滤,
|
||||
// 用于断言「物理上也不存在」。
|
||||
func hardCount(t *testing.T, db *gorm.DB, model interface{}, where string, args ...interface{}) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
err := db.WithContext(context.Background()).Unscoped().
|
||||
Model(model).
|
||||
Where(where, args...).
|
||||
Count(&n).Error
|
||||
require.NoError(t, err)
|
||||
return n
|
||||
}
|
||||
@@ -23,7 +23,12 @@ type UploadImagesStore interface {
|
||||
// 用于账号导出等场景,将 N 次单题查询合并为少量 IN 查询,避免 N+1。
|
||||
GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*UploadImage, error)
|
||||
GetByTypeQuestionID(ctx context.Context, typ UploadImageQuestionType, questionID uint) ([]*UploadImage, error)
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部上传图片记录(软删除,与提问保持一致)。
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部上传图片记录与关联行。
|
||||
//
|
||||
// 用 Unscoped 真删:图片元数据(name/md5/key)随提问一并真删,与 questions.DeleteByID
|
||||
// 对主表的真删口径一致。软删会留下带 deleted_at 的图片行,且关联行的 question_id
|
||||
// 指向已不存在的提问。注意这里只清数据库记录,对象存储里的实际文件不在本函数删除
|
||||
// (图片可能被多条记录复用,避免误删共享资源)。
|
||||
DeleteByQuestionID(ctx context.Context, questionID uint) error
|
||||
}
|
||||
|
||||
@@ -179,9 +184,10 @@ func (db *uploadImages) DeleteByQuestionID(ctx context.Context, questionID uint)
|
||||
imageIDs := lo.Map(links, func(item *UploadImageQuestion, _ int) uint {
|
||||
return item.UploadImageID
|
||||
})
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", imageIDs).Delete(&UploadImage{}).Error; err != nil {
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", imageIDs).Unscoped().Delete(&UploadImage{}).Error; err != nil {
|
||||
return errors.Wrap(err, "delete upload images by question id")
|
||||
}
|
||||
// 关联表 UploadImageQuestion 无 DeletedAt,Delete 本就是真删,无需 Unscoped。
|
||||
if err := tx.WithContext(ctx).Where("question_id = ?", questionID).Delete(&UploadImageQuestion{}).Error; err != nil {
|
||||
return errors.Wrap(err, "delete image question links")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user