diff --git a/internal/db/follow_ups.go b/internal/db/follow_ups.go index 68ebc58..20da909 100644 --- a/internal/db/follow_ups.go +++ b/internal/db/follow_ups.go @@ -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 diff --git a/internal/db/question_delete_hard_test.go b/internal/db/question_delete_hard_test.go new file mode 100644 index 0000000..635db3c --- /dev/null +++ b/internal/db/question_delete_hard_test.go @@ -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 +} diff --git a/internal/db/upload_images.go b/internal/db/upload_images.go index 22f44d1..6067201 100644 --- a/internal/db/upload_images.go +++ b/internal/db/upload_images.go @@ -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") }