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:
tamakyi
2026-10-29 18:52:19 +08:00
parent 7f86db8937
commit 87176516f3
3 changed files with 109 additions and 5 deletions
+94
View File
@@ -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
}