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:
3
.gitignore
vendored
3
.gitignore
vendored
@ -20,6 +20,9 @@ gotest*.exe
|
|||||||
# 数据库备份
|
# 数据库备份
|
||||||
/db/backups/
|
/db/backups/
|
||||||
|
|
||||||
|
# dbtool 陈旧导出产物(权威 schema 是仓库根目录的 /db_dump.sql,勿混淆)
|
||||||
|
cmd/dbtool/db_dump.*
|
||||||
|
|
||||||
# 本地配置
|
# 本地配置
|
||||||
configs/config.local.yml
|
configs/config.local.yml
|
||||||
|
|
||||||
|
|||||||
37096
cmd/dbtool/db_dump.json
37096
cmd/dbtool/db_dump.json
File diff suppressed because one or more lines are too long
@ -1,9 +1,10 @@
|
|||||||
-- 单表发布模型(方案 2):取消草稿表,审核态由 status 承载,公开读走只读视图。
|
-- 单表发布模型(方案 2):取消草稿表,审核态由 status 承载,公开读走只读视图。
|
||||||
--
|
--
|
||||||
-- 本文件只做三件事:加列 / 存量行置已发布 / 建视图。幂等,可重复执行。
|
-- 本文件只做两件事:加列 / 建视图。幂等,可重复执行。
|
||||||
|
-- 存量行置已发布不在本文件里(它是只能执行一次的数据变更,见 2026-09-22-01b-publish-existing-rows.sql)。
|
||||||
-- 数据搬迁由一次性脚本 scripts/migrate_single_table/main.go 完成。该脚本已随本次改造从工作树删除
|
-- 数据搬迁由一次性脚本 scripts/migrate_single_table/main.go 完成。该脚本已随本次改造从工作树删除
|
||||||
--(它 import 已删除的草稿模型,无法在改造后的代码树上编译);取回方式与执行顺序见本目录 README.md。
|
--(它 import 已删除的草稿模型,无法在改造后的代码树上编译);取回方式与执行顺序见本目录 README.md。
|
||||||
-- 删草稿表见 2026-09-22-02-drop-draft-tables.sql。
|
-- 删草稿表见 2026-09-22-05-drop-draft-tables.sql。
|
||||||
|
|
||||||
-- 1) 正式表补审核态与溯源列。默认 'pending' 是刻意的(fail-closed):
|
-- 1) 正式表补审核态与溯源列。默认 'pending' 是刻意的(fail-closed):
|
||||||
-- 任何漏赋值的行默认不可见,而不是意外对外发布。
|
-- 任何漏赋值的行默认不可见,而不是意外对外发布。
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 1 | `2026-09-21-01-street-main-detail.sql` | 草稿表与正式图片表加 `is_detail` / `parent_image_id`。全 `IF NOT EXISTS`,幂等。 |
|
| 1 | `2026-09-21-01-street-main-detail.sql` | 草稿表与正式图片表加 `is_detail` / `parent_image_id`。全 `IF NOT EXISTS`,幂等。 |
|
||||||
| 2 | `2026-09-21-02-duplicate-review.sql` | 新建 `image_duplicates`,街拍正式表加 `source` / `source_url` 及部分唯一索引 `uq_ss_source`。幂等。 |
|
| 2 | `2026-09-21-02-duplicate-review.sql` | 新建 `image_duplicates`,街拍正式表加 `source` / `source_url` 及部分唯一索引 `uq_ss_source`。幂等。 |
|
||||||
| 3 | `2026-09-21-03-ingest-source-idempotency.sql` | 两张**草稿表**加 `source` / `source_url` 及部分唯一索引。幂等。这些列会随 2026-09-22-02 删草稿表一并消失。 |
|
| 3 | `2026-09-21-03-ingest-source-idempotency.sql` | 两张**草稿表**加 `source` / `source_url` 及部分唯一索引。幂等。这些列会随 2026-09-22-05 删草稿表一并消失。 |
|
||||||
|
|
||||||
这三步属于更早的改造;对已经过它们的库重放无害。
|
这三步属于更早的改造;对已经过它们的库重放无害。
|
||||||
|
|
||||||
@ -22,16 +22,16 @@
|
|||||||
|
|
||||||
## 二、2026-09-22 单表发布(草稿表 → 单表 + `status` + 公开只读视图)
|
## 二、2026-09-22 单表发布(草稿表 → 单表 + `status` + 公开只读视图)
|
||||||
|
|
||||||
**执行顺序:`01 → 01b → 搬迁脚本 → 02 → 03 → 04`**
|
**执行顺序:`01 → 01b → 03 → 04 → 搬迁脚本 → 05`**(与文件名排序一致:`01`、`01b`、`03`、`04`、`05`;搬迁脚本是一段独立命令,夹在 `04` 与 `05` 之间执行)
|
||||||
|
|
||||||
| 顺序 | 对象 | 前提 / 幂等 / 不可逆点 |
|
| 顺序 | 对象 | 前提 / 幂等 / 不可逆点 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 1 | `2026-09-22-01-single-table-publish.sql` | 给 `brand_runways` / `street_snaps` 加 `status` 等列、建状态索引、建 4 个 `public_*` 视图。**幂等**,可重复执行(集成测试每次都会跑它)。 |
|
| 1 | `2026-09-22-01-single-table-publish.sql` | 给 `brand_runways` / `street_snaps` 加 `status` 等列、建状态索引、建 4 个 `public_*` 视图。**幂等**,可重复执行(集成测试每次都会跑它)。 |
|
||||||
| 2 | `2026-09-22-01b-publish-existing-rows.sql` | 把存量正式行置 `published`。**只能执行一次**;且必须在新代码部署**之前**、搬迁脚本**之前**执行。理由:搬迁会把 pending 草稿连同旧 `created_at` 一起搬进来,若这条 UPDATE 可重放,待审内容会被误刷成已发布(本设计要防的就是这个泄漏)。 |
|
| 2 | `2026-09-22-01b-publish-existing-rows.sql` | 把存量正式行置 `published`。**只能执行一次**;且必须在新代码部署**之前**、搬迁脚本**之前**执行。理由:搬迁会把 pending 草稿连同旧 `created_at` 一起搬进来,若这条 UPDATE 可重放,待审内容会被误刷成已发布(本设计要防的就是这个泄漏)。 |
|
||||||
| 3 | 搬迁脚本 `<见下>` | 把草稿表数据搬进正式表。**一次性**;先 `-dry-run` + 守恒校验,**校验不过绝不进入第 4 步**。 |
|
| 3 | `2026-09-22-03-entity-key-unique.sql` | 实体键部分唯一索引 `uq_br_entity` / `uq_ss_entity`(街拍为最终表达式 `(city, year, COALESCE(title,''))`)。**幂等**;建索引前先体检,有重复就报可读错误而非原生 23505。 |
|
||||||
| 4 | `2026-09-22-02-drop-draft-tables.sql` | 删除 4 张草稿表。**不可逆**;前提是第 3 步校验通过,且 `db/backups/` 有可用备份。 |
|
| 4 | `2026-09-22-04-street-entity-key-title.sql` | 街拍索引的**幂等兜底**:`DROP INDEX IF EXISTS uq_ss_entity` 后按最终表达式重建。常态由 03 承载;本文件专治「库里已有同名旧 `(city, year)` 索引、03 因 `IF NOT EXISTS` 静默跳过」的存量库。 |
|
||||||
| 5 | `2026-09-22-03-entity-key-unique.sql` | 实体键部分唯一索引 `uq_br_entity` / `uq_ss_entity`(街拍为最终表达式 `(city, year, COALESCE(title,''))`)。**幂等**;建索引前先体检,有重复就报可读错误而非原生 23505。 |
|
| 5 | 搬迁脚本 `<见下>` | 把草稿表数据搬进正式表。**一次性**;先 `-dry-run` + 守恒校验,**校验不过绝不进入第 6 步**。 |
|
||||||
| 6 | `2026-09-22-04-street-entity-key-title.sql` | 街拍索引的**幂等兜底**:`DROP INDEX IF EXISTS uq_ss_entity` 后按最终表达式重建。常态由 03 承载;本文件专治「库里已有同名旧 `(city, year)` 索引、03 因 `IF NOT EXISTS` 静默跳过」的存量库。 |
|
| 6 | `2026-09-22-05-drop-draft-tables.sql` | 删除 4 张草稿表。**不可逆**;前提是第 5 步校验通过,且 `db/backups/` 有可用备份。⚠️ 编号排在最后(`05`)是刻意的:按文件名顺序执行的运维者不会在搬迁之前误删草稿表。 |
|
||||||
|
|
||||||
### 搬迁脚本的取回方式
|
### 搬迁脚本的取回方式
|
||||||
|
|
||||||
|
|||||||
@ -206,7 +206,7 @@ UPDATE street_snaps SET status = 'published' WHERE status = 'pending';
|
|||||||
|
|
||||||
> `approved` 草稿的内容已由当年的晋升写进正式表,**不重复插入**(只丢弃元数据)。
|
> `approved` 草稿的内容已由当年的晋升写进正式表,**不重复插入**(只丢弃元数据)。
|
||||||
|
|
||||||
### 第 3 步:删表(`db/migrations/2026-09-22-02-drop-draft-tables.sql`,校验通过后再执行)
|
### 第 3 步:删表(`db/migrations/2026-09-22-05-drop-draft-tables.sql`,校验通过后再执行)
|
||||||
```sql
|
```sql
|
||||||
DROP TABLE IF EXISTS brand_runway_draft_images, brand_runway_drafts,
|
DROP TABLE IF EXISTS brand_runway_draft_images, brand_runway_drafts,
|
||||||
street_snap_draft_images, street_snap_drafts;
|
street_snap_draft_images, street_snap_drafts;
|
||||||
@ -221,7 +221,7 @@ DROP TABLE IF EXISTS brand_runway_draft_images, brand_runway_drafts,
|
|||||||
| service | `ingest_service.go`、`review_service.go`、`article_service.go`、`street_snap_service.go`、`brand_service.go`、`index_service.go` |
|
| service | `ingest_service.go`、`review_service.go`、`article_service.go`、`street_snap_service.go`、`brand_service.go`、`index_service.go` |
|
||||||
| handler | `backstage_handler.go`(去掉 `?main=` 之外基本不动)、`article_handler.go`/`street_snap_handler.go`/`brand_handler.go`/`ssg_handler.go`(应无需改动,公开过滤下沉到仓储) |
|
| handler | `backstage_handler.go`(去掉 `?main=` 之外基本不动)、`article_handler.go`/`street_snap_handler.go`/`brand_handler.go`/`ssg_handler.go`(应无需改动,公开过滤下沉到仓储) |
|
||||||
| templates | `review-list.html`(status 值)、`review-detail.html`(`.Draft`→`.Record`)、`runways.html`、`street-snaps.html`(状态列) |
|
| templates | `review-list.html`(status 值)、`review-detail.html`(`.Draft`→`.Record`)、`runways.html`、`street-snaps.html`(状态列) |
|
||||||
| DDL / 脚本 | `db/migrations/2026-09-22-01-*.sql`、`db/migrations/2026-09-22-02-*.sql`、`scripts/migrate_single_table/main.go` |
|
| DDL / 脚本 | `db/migrations/2026-09-22-01-*.sql`、`db/migrations/2026-09-22-05-*.sql`、`scripts/migrate_single_table/main.go` |
|
||||||
| 测试 | `street_main_detail_integration_test.go`、`dedup_integration_test.go`、`ingest_repository_test.go`、`backstage_handler_test.go`、`router/backstage_test.go`、service 层单测 |
|
| 测试 | `street_main_detail_integration_test.go`、`dedup_integration_test.go`、`ingest_repository_test.go`、`backstage_handler_test.go`、`router/backstage_test.go`、service 层单测 |
|
||||||
|
|
||||||
## 10. 测试策略
|
## 10. 测试策略
|
||||||
|
|||||||
@ -33,7 +33,7 @@ type ArticleRepository interface {
|
|||||||
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
|
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
|
||||||
// UpdateRunway 审核/编辑后更新字段(白名单,杜绝任意列注入)。
|
// UpdateRunway 审核/编辑后更新字段(白名单,杜绝任意列注入)。
|
||||||
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
|
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
// DeleteRunwayImage 软删单张走秀图片。
|
// DeleteRunwayImage 软删单张走秀图片,并按实际存活主图行数重算 image_count。
|
||||||
DeleteRunwayImage(ctx context.Context, imageID uint32) error
|
DeleteRunwayImage(ctx context.Context, imageID uint32) error
|
||||||
// GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。
|
// GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。
|
||||||
GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error)
|
GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error)
|
||||||
@ -317,11 +317,36 @@ func (r *articleRepository) UpdateRunway(ctx context.Context, id uint32, fields
|
|||||||
Updates(clean).Error
|
Updates(clean).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteRunwayImage 软删单张走秀图片(后台正式编辑页删图),并同步重算 image_count。
|
||||||
|
//
|
||||||
|
// 与审核侧 SoftDeleteRunwayImage 口径一致:删完按**实际存活主图行数**重算
|
||||||
|
// (`is_deleted = 0 AND is_detail = 0`,细节图不计入)。
|
||||||
|
//
|
||||||
|
// 为什么必须重算而不是「递减 1」:单表发布模型删掉了「晋升时按实际图片行重算」的自愈路径,
|
||||||
|
// 编辑页删图若只递减,过期页面 / 重复提交 / 跨记录重放(命中 0 行仍递减)造成的漂移会永久固化,
|
||||||
|
// 并直接显示在公开列表卡片的「N 张」角标里。
|
||||||
|
//
|
||||||
|
// 入参只有 imageID(拿不到父记录 id),故在一个事务里三步走:取 runway_id → 软删 → 重算。
|
||||||
|
// 图片行不存在时与旧行为一致:静默无操作(不报错、不重算)。
|
||||||
func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error {
|
func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error {
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
Model(&model.BrandRunwayImage{}).
|
var img model.BrandRunwayImage
|
||||||
Where("id = ? AND is_deleted = 0", imageID).
|
err := tx.Select("runway_id").Where("id = ?", imageID).First(&img).Error
|
||||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||||
|
Where("id = ? AND is_deleted = 0", imageID).
|
||||||
|
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Exec(`UPDATE brand_runways SET image_count =
|
||||||
|
(SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0)
|
||||||
|
WHERE id = ?`, img.RunwayID, img.RunwayID).Error
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error) {
|
func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error) {
|
||||||
|
|||||||
198
internal/repository/editor_image_delete_integration_test.go
Normal file
198
internal/repository/editor_image_delete_integration_test.go
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// 集成测试:后台正式编辑页(ArticleRepository / StreetSnapRepository)删图后,
|
||||||
|
// image_count 必须按实际存活行**重算** —— 与审核侧 SoftDelete*Image 口径完全一致。
|
||||||
|
//
|
||||||
|
// 为什么必须有这条:单表发布模型删掉了「晋升时按实际图片行重算」的自愈路径。
|
||||||
|
// 旧流程下编辑页删图造成的计数漂移会在下次发布被抹平;现在不会了,漂移会永久留在公开列表
|
||||||
|
// 卡片的「N 张」角标上。入口链路:templates/pages/*-edit.html 删除按钮 →
|
||||||
|
// backstage_handler.go → article_service/street_snap_service.DeleteImage → 本仓储方法。
|
||||||
|
//
|
||||||
|
// 运行:go test -tags integration ./internal/repository/ -run TestEditorDeleteImage -v
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// editorSeedSeq 让每次 seed 出来的实体键互不相同(避开 uq_br_entity / uq_ss_entity 部分唯一索引)。
|
||||||
|
var editorSeedSeq int
|
||||||
|
|
||||||
|
// seedEditorRunway 插入一条走秀记录(image_count 预置为入参,便于观察是否被重算)并返回其 id。
|
||||||
|
func seedEditorRunway(t *testing.T, db *gorm.DB, imageCount uint16) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
editorSeedSeq++
|
||||||
|
now := uint32(time.Now().Unix())
|
||||||
|
rw := model.BrandRunway{
|
||||||
|
BrandID: 1, TitleEn: "editor-delete-test", Year: 2026,
|
||||||
|
SeasonCode: fmt.Sprintf("ED%04d", editorSeedSeq), CollectionType: "rtw",
|
||||||
|
Status: model.StatusPending, ImageCount: imageCount, CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := db.Create(&rw).Error; err != nil {
|
||||||
|
t.Fatalf("插入走秀记录失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", rw.ID)
|
||||||
|
db.Exec("DELETE FROM brand_runways WHERE id = ?", rw.ID)
|
||||||
|
})
|
||||||
|
return rw.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedEditorRunwayImage 插入一张走秀图(isDetail=1 表示细节图)并返回行 id。
|
||||||
|
func seedEditorRunwayImage(t *testing.T, db *gorm.DB, runwayID uint32, name string, order uint32, isDetail uint8) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
img := model.BrandRunwayImage{
|
||||||
|
RunwayID: runwayID, BrandID: 1, Image: name + ".jpg", Name: name,
|
||||||
|
SortOrder: order, IsDetail: isDetail,
|
||||||
|
}
|
||||||
|
if err := db.Create(&img).Error; err != nil {
|
||||||
|
t.Fatalf("插入走秀图片失败: %v", err)
|
||||||
|
}
|
||||||
|
return img.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedEditorSnap 插入一条街拍记录(image_count 预置为入参)并返回其 id。
|
||||||
|
func seedEditorSnap(t *testing.T, db *gorm.DB, imageCount uint16) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
editorSeedSeq++
|
||||||
|
now := uint32(time.Now().Unix())
|
||||||
|
snap := model.StreetSnap{
|
||||||
|
Title: "editor-delete-test", City: fmt.Sprintf("EditorCity-%d", editorSeedSeq),
|
||||||
|
Year: 2026, Status: model.StatusPending, ImageCount: imageCount,
|
||||||
|
CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := db.Create(&snap).Error; err != nil {
|
||||||
|
t.Fatalf("插入街拍记录失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snap.ID)
|
||||||
|
db.Exec("DELETE FROM street_snaps WHERE id = ?", snap.ID)
|
||||||
|
})
|
||||||
|
return snap.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedEditorSnapImage 插入一张街拍图(isDetail=1 表示副图)并返回行 id。
|
||||||
|
func seedEditorSnapImage(t *testing.T, db *gorm.DB, snapID uint32, name string, order uint32, isDetail uint8, parent uint32) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
img := model.StreetSnapImage{
|
||||||
|
SnapID: snapID, Image: name + ".jpg", Name: name, SortOrder: order,
|
||||||
|
IsDetail: isDetail, ParentImageID: parent,
|
||||||
|
}
|
||||||
|
if err := db.Create(&img).Error; err != nil {
|
||||||
|
t.Fatalf("插入街拍图片失败: %v", err)
|
||||||
|
}
|
||||||
|
return img.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorImageCount 读某记录的 image_count(table / idCol 由测试常量指定,无注入面)。
|
||||||
|
func editorImageCount(t *testing.T, db *gorm.DB, table, idCol string, id uint32) uint16 {
|
||||||
|
t.Helper()
|
||||||
|
var row struct {
|
||||||
|
ImageCount uint16 `gorm:"column:image_count"`
|
||||||
|
}
|
||||||
|
if err := db.Table(table).Select("image_count").Where(idCol+" = ?", id).Scan(&row).Error; err != nil {
|
||||||
|
t.Fatalf("读 %s.image_count 失败: %v", table, err)
|
||||||
|
}
|
||||||
|
return row.ImageCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEditorDeleteRunwayImageRecomputesCount 走秀编辑页删图:口径 = 存活主图行数(is_detail=0)。
|
||||||
|
func TestEditorDeleteRunwayImageRecomputesCount(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewArticleRepository(db)
|
||||||
|
|
||||||
|
id := seedEditorRunway(t, db, 2) // 与「2 张主图」一致的初始值
|
||||||
|
keep := seedEditorRunwayImage(t, db, id, "keep", 1, 0)
|
||||||
|
drop := seedEditorRunwayImage(t, db, id, "drop", 2, 0)
|
||||||
|
detail := seedEditorRunwayImage(t, db, id, "detail", 3, 1)
|
||||||
|
|
||||||
|
if got := editorImageCount(t, db, "brand_runways", "id", id); got != 2 {
|
||||||
|
t.Fatalf("前置 image_count 应为 2,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删一张主图:应按存活主图行数重算为 1。若只置 is_deleted 不重算,会停在 2。
|
||||||
|
if err := repo.DeleteRunwayImage(ctx, drop); err != nil {
|
||||||
|
t.Fatalf("编辑页删图失败: %v", err)
|
||||||
|
}
|
||||||
|
if got := editorImageCount(t, db, "brand_runways", "id", id); got != 1 {
|
||||||
|
t.Fatalf("删一张主图后 image_count 应重算为 1,实际 %d(只置 is_deleted 不重算会停在 2)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删细节图:口径只计主图,计数不变(锁住 is_detail = 0 过滤)。
|
||||||
|
if err := repo.DeleteRunwayImage(ctx, detail); err != nil {
|
||||||
|
t.Fatalf("删细节图失败: %v", err)
|
||||||
|
}
|
||||||
|
if got := editorImageCount(t, db, "brand_runways", "id", id); got != 1 {
|
||||||
|
t.Fatalf("删细节图不应改变主图计数,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重复删同一张(已软删):重放幂等,计数不变。
|
||||||
|
if err := repo.DeleteRunwayImage(ctx, drop); err != nil {
|
||||||
|
t.Fatalf("重复删同一张应幂等,实际报错: %v", err)
|
||||||
|
}
|
||||||
|
if got := editorImageCount(t, db, "brand_runways", "id", id); got != 1 {
|
||||||
|
t.Fatalf("重复删同一张不应改变 image_count,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不存在的 imageID:静默无操作,不得影响计数。
|
||||||
|
if err := repo.DeleteRunwayImage(ctx, 4_294_967_290); err != nil {
|
||||||
|
t.Fatalf("不存在的 imageID 不应报错: %v", err)
|
||||||
|
}
|
||||||
|
if got := editorImageCount(t, db, "brand_runways", "id", id); got != 1 {
|
||||||
|
t.Fatalf("不存在的 imageID 不应改变 image_count,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var alive []uint32
|
||||||
|
if err := db.Model(&model.BrandRunwayImage{}).
|
||||||
|
Where("runway_id = ? AND is_deleted = 0", id).Order("id ASC").
|
||||||
|
Pluck("id", &alive).Error; err != nil {
|
||||||
|
t.Fatalf("读存活图失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(alive) != 1 || alive[0] != keep {
|
||||||
|
t.Fatalf("应只剩 keep 一行,实际 %v", alive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEditorDeleteSnapImageRecomputesCount 街拍编辑页删图:口径 = 全部存活图片行(主图 + 副图)。
|
||||||
|
func TestEditorDeleteSnapImageRecomputesCount(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewStreetSnapRepository(db)
|
||||||
|
|
||||||
|
id := seedEditorSnap(t, db, 2)
|
||||||
|
main := seedEditorSnapImage(t, db, id, "main", 1, 0, 0)
|
||||||
|
detail := seedEditorSnapImage(t, db, id, "detail", 2, 1, main)
|
||||||
|
|
||||||
|
// 删主图:副图仍存活,街拍口径计全部存活行 → 应重算为 1。
|
||||||
|
// 若误用走秀的「只计主图」口径,会得 0 —— 本断言锁得住。
|
||||||
|
if err := repo.DeleteSnapImage(ctx, main); err != nil {
|
||||||
|
t.Fatalf("编辑页删图失败: %v", err)
|
||||||
|
}
|
||||||
|
if got := editorImageCount(t, db, "street_snaps", "id", id); got != 1 {
|
||||||
|
t.Fatalf("删主图后 image_count 应按存活行重算为 1(副图也计入),实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重复删同一张(已软删):重放幂等,计数不变。
|
||||||
|
if err := repo.DeleteSnapImage(ctx, main); err != nil {
|
||||||
|
t.Fatalf("重复删同一张应幂等,实际报错: %v", err)
|
||||||
|
}
|
||||||
|
if got := editorImageCount(t, db, "street_snaps", "id", id); got != 1 {
|
||||||
|
t.Fatalf("重复删同一张不应改变 image_count,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删副图:全部删完 → 0。
|
||||||
|
if err := repo.DeleteSnapImage(ctx, detail); err != nil {
|
||||||
|
t.Fatalf("删副图失败: %v", err)
|
||||||
|
}
|
||||||
|
if got := editorImageCount(t, db, "street_snaps", "id", id); got != 0 {
|
||||||
|
t.Fatalf("删完所有图后 image_count 应为 0,实际 %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,7 +35,7 @@ type StreetSnapRepository interface {
|
|||||||
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
|
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
|
||||||
// UpdateSnap 编辑后更新字段(白名单,杜绝任意列注入)。
|
// UpdateSnap 编辑后更新字段(白名单,杜绝任意列注入)。
|
||||||
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
|
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
// DeleteSnapImage 软删单张街拍图片。
|
// DeleteSnapImage 软删单张街拍图片,并按实际存活图片行数重算 image_count。
|
||||||
DeleteSnapImage(ctx context.Context, imageID uint32) error
|
DeleteSnapImage(ctx context.Context, imageID uint32) error
|
||||||
// GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。
|
// GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。
|
||||||
GetSnapImageKey(ctx context.Context, imageID uint32) (string, error)
|
GetSnapImageKey(ctx context.Context, imageID uint32) (string, error)
|
||||||
@ -261,11 +261,35 @@ func (r *streetSnapRepository) UpdateSnap(ctx context.Context, id uint32, fields
|
|||||||
Updates(clean).Error
|
Updates(clean).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteSnapImage 软删单张街拍图片(后台正式编辑页删图),并同步重算 image_count。
|
||||||
|
//
|
||||||
|
// 与审核侧 SoftDeleteStreetImage 口径一致:删完按**实际存活图片行数**重算
|
||||||
|
// (`is_deleted = 0`,主图 + 副图全部计入)。
|
||||||
|
//
|
||||||
|
// 为什么必须重算而不是「递减 1」:理由同 articleRepository.DeleteRunwayImage —— 单表发布模型
|
||||||
|
// 删掉了晋升重建,编辑页删图造成的计数漂移不再被抹平,会永久显示在公开列表卡片的「N 张」上。
|
||||||
|
//
|
||||||
|
// 入参只有 imageID(拿不到父记录 id),故在一个事务里三步走:取 snap_id → 软删 → 重算。
|
||||||
|
// 图片行不存在时与旧行为一致:静默无操作(不报错、不重算)。
|
||||||
func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error {
|
func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error {
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
Model(&model.StreetSnapImage{}).
|
var img model.StreetSnapImage
|
||||||
Where("id = ? AND is_deleted = 0", imageID).
|
err := tx.Select("snap_id").Where("id = ?", imageID).First(&img).Error
|
||||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.StreetSnapImage{}).
|
||||||
|
Where("id = ? AND is_deleted = 0", imageID).
|
||||||
|
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Exec(`UPDATE street_snaps SET image_count =
|
||||||
|
(SELECT COUNT(*) FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0)
|
||||||
|
WHERE id = ?`, img.SnapID, img.SnapID).Error
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint32) (string, error) {
|
func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint32) (string, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user