fix(publish): 最终审查补修(编辑页删图重算计数、迁移编号后置、图片 id 稳定断言)
D1 编辑页删图:DeleteRunwayImage/DeleteSnapImage 在单事务内取父 id→软删→按存活行重算 image_count,口径与审核侧 SoftDelete*Image 完全一致(走秀只计主图、街拍计全部),并补集成测试(含重复删同一张) D2 不可逆删表迁移 02→05(git mv),使文件名顺序 01→01b→03→04→05 与必需执行顺序一致;同步 README/文件头/规格/计划全部引用 D3 新增「通过审核后图片行 id 集合完全一致」的集成断言,锁住单表发布头号收益 D4 修正 01 迁移文件头「只做三件事」自相矛盾表述;D5 删除受版本控制的陈旧产物 cmd/dbtool/db_dump.json 并加 gitignore 规则;D6 清理遗留空目录 scripts/tmp_verify/
This commit is contained in:
140
internal/repository/image_id_stability_integration_test.go
Normal file
140
internal/repository/image_id_stability_integration_test.go
Normal file
@ -0,0 +1,140 @@
|
||||
//go:build integration
|
||||
|
||||
// 集成测试:单表发布模型的头号收益 —— 审核通过不再重建图片,**图片行 id 终身不变**。
|
||||
//
|
||||
// 旧流程(草稿表 → 晋升)会在通过时把草稿图片重新插入正式图片表,图片行 id 必然改变;
|
||||
// 任何按 id 引用的下游(CDN 缓存键、分享链接、埋点)都会随之失效。改造把「通过」降级为
|
||||
// 只改 status,本文件用「id 集合一字不差」把这条收益钉死。
|
||||
//
|
||||
// 为什么现有测试看不到它:
|
||||
// - router/backstage_test.go 的桩只断言 op == "approve",根本看不到图片行 id;
|
||||
// - 公开视图测试都是直接 Update("status"),绕过了 SetRecordStatus / SetStreetRecordStatus。
|
||||
//
|
||||
// 反证(本断言为何能区分「重建图片」的假实现):若把 SetRecordStatus 换成任何会重建图片的实现
|
||||
// (先删旧图再插入、或从别处拷贝重建),通过前记下的 id 与通过后必然不同(旧 id 消失、出现新 id),
|
||||
// 本断言立即变红。断言只比对「同一记录存活图片行的 id 序列」,不依赖计数或其它副作用,
|
||||
// 因此不会被与重建无关的改动误伤,也不会因无关改动静默通过。
|
||||
//
|
||||
// 运行:go test -tags integration ./internal/repository/ -run TestApproveKeepsImageRowIDs -v
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/model"
|
||||
)
|
||||
|
||||
// sameUint32IDs 逐元素比较两个 id 序列(长度 + 顺序)。
|
||||
func sameUint32IDs(a, b []uint32) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestApproveKeepsImageRowIDs 走秀:pending 记录(3 张图)经 SetRecordStatus(published) 通过后,
|
||||
// status 变为 published,且图片行 id 集合与通过前完全一致。
|
||||
func TestApproveKeepsImageRowIDs(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
repo := NewReviewRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
id := seedRunway(t, db, model.StatusPending, 1992) // seedRunway 已插 1 张主图
|
||||
now := uint32(1_800_000_000)
|
||||
for i, name := range []string{"second", "third"} {
|
||||
img := model.BrandRunwayImage{
|
||||
RunwayID: id, BrandID: 1, Image: name + ".jpg", Name: name,
|
||||
SortOrder: uint32(i + 2), CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(&img).Error; err != nil {
|
||||
t.Fatalf("插入走秀图片失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
imageIDs := func() []uint32 {
|
||||
var ids []uint32
|
||||
if err := db.Model(&model.BrandRunwayImage{}).
|
||||
Where("runway_id = ?", id).Order("id ASC").Pluck("id", &ids).Error; err != nil {
|
||||
t.Fatalf("读图片行 id 失败: %v", err)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
before := imageIDs()
|
||||
if len(before) != 3 {
|
||||
t.Fatalf("前置应恰有 3 行图片,实际 %d", len(before))
|
||||
}
|
||||
|
||||
if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin", ""); err != nil {
|
||||
t.Fatalf("SetRecordStatus(published) 出错: %v", err)
|
||||
}
|
||||
|
||||
var rw model.BrandRunway
|
||||
if err := db.Where("id = ?", id).First(&rw).Error; err != nil {
|
||||
t.Fatalf("读回记录失败: %v", err)
|
||||
}
|
||||
if rw.Status != model.StatusPublished {
|
||||
t.Fatalf("status 应为 published,实际 %s", rw.Status)
|
||||
}
|
||||
|
||||
if after := imageIDs(); !sameUint32IDs(before, after) {
|
||||
t.Fatalf("通过审核不得重建图片:图片行 id 应完全一致,通过前 %v,通过后 %v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApproveKeepsSnapImageRowIDs 街拍:与走秀同形,覆盖另一组记录/图片表与方法。
|
||||
func TestApproveKeepsSnapImageRowIDs(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
repo := NewReviewRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
id := seedStreetSnap(t, db, model.StatusPending, 1986) // 已插 1 张图
|
||||
now := uint32(1_800_000_000)
|
||||
for i, name := range []string{"second", "third"} {
|
||||
img := model.StreetSnapImage{
|
||||
SnapID: id, Image: name + ".jpg", Name: name,
|
||||
SortOrder: uint32(i + 2), CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(&img).Error; err != nil {
|
||||
t.Fatalf("插入街拍图片失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
imageIDs := func() []uint32 {
|
||||
var ids []uint32
|
||||
if err := db.Model(&model.StreetSnapImage{}).
|
||||
Where("snap_id = ?", id).Order("id ASC").Pluck("id", &ids).Error; err != nil {
|
||||
t.Fatalf("读图片行 id 失败: %v", err)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
before := imageIDs()
|
||||
if len(before) != 3 {
|
||||
t.Fatalf("前置应恰有 3 行图片,实际 %d", len(before))
|
||||
}
|
||||
|
||||
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin", ""); err != nil {
|
||||
t.Fatalf("SetStreetRecordStatus(published) 出错: %v", err)
|
||||
}
|
||||
|
||||
var snap model.StreetSnap
|
||||
if err := db.Where("id = ?", id).First(&snap).Error; err != nil {
|
||||
t.Fatalf("读回记录失败: %v", err)
|
||||
}
|
||||
if snap.Status != model.StatusPublished {
|
||||
t.Fatalf("status 应为 published,实际 %s", snap.Status)
|
||||
}
|
||||
|
||||
if after := imageIDs(); !sameUint32IDs(before, after) {
|
||||
t.Fatalf("通过审核不得重建图片:图片行 id 应完全一致,通过前 %v,通过后 %v", before, after)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user