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, 并做变异验证(改回软删 → 测试失败)证明测试真的能抓住这个回归。
95 lines
3.1 KiB
Go
95 lines
3.1 KiB
Go
// 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
|
|
}
|