feat(street): 草稿图支持主副分组(并入/并入上一张/拆出)
This commit is contained in:
@ -4,7 +4,7 @@
|
|||||||
// **单个纯 SQL 文件**,拷到另一台机器后用 dbtool 或 psql 灌入即可 —— 无需 pg_dump。
|
// **单个纯 SQL 文件**,拷到另一台机器后用 dbtool 或 psql 灌入即可 —— 无需 pg_dump。
|
||||||
//
|
//
|
||||||
// dbtool dump [-config <yml>] [-out <file.sql>] [-with-extension] [-clean] 导出
|
// dbtool dump [-config <yml>] [-out <file.sql>] [-with-extension] [-clean] 导出
|
||||||
// dbtool import [-config <yml>] [-in <file.sql>] 导入
|
// dbtool import [-config <yml>] [-in <file.sql>] [-clean] 导入
|
||||||
//
|
//
|
||||||
// 导入(目标机器,二选一):
|
// 导入(目标机器,二选一):
|
||||||
//
|
//
|
||||||
@ -30,9 +30,17 @@
|
|||||||
// 目标库为空还是已有数据:
|
// 目标库为空还是已有数据:
|
||||||
//
|
//
|
||||||
// 默认只 CREATE TABLE IF NOT EXISTS + INSERT,**不清表**,因此要求目标库为空
|
// 默认只 CREATE TABLE IF NOT EXISTS + INSERT,**不清表**,因此要求目标库为空
|
||||||
// (对已有数据的库会主键冲突)。想让同一份文件直接覆盖一个已有数据的库,
|
// (对已有数据的库会主键冲突)。要覆盖一个已有数据的库,用 -clean:
|
||||||
// 导出时加 -clean —— 它会在结构段前输出 DROP TABLE IF EXISTS ... CASCADE,
|
//
|
||||||
// 且只针对本次 dump 里的表(与 pg_dump --clean 口径一致,目标库多出来的表不动)。
|
// dump -clean 在结构段前输出 DROP TABLE IF EXISTS ... CASCADE,只针对本次
|
||||||
|
// dump 里的表(与 pg_dump --clean 口径一致),清理语义写在文件里,
|
||||||
|
// 因此 psql -f 同样能灌进脏库。
|
||||||
|
// import -clean 导入前 DROP public 下全部表,不必重新导出文件;适合「手上只有
|
||||||
|
// 一份旧文件、目标库状态不明」的情况。
|
||||||
|
//
|
||||||
|
// 约束段另有一层幂等保护:先 DROP CONSTRAINT IF EXISTS 再 ADD。这样即使目标库里
|
||||||
|
// 已存在同名主键(例如早先被 AutoMigrate 建过、或上一次导入中断留下的),
|
||||||
|
// 也不会报 relation "<name>" already exists。
|
||||||
//
|
//
|
||||||
// 前置条件:目标库必须已启用 pgvector 扩展,否则 vector(64) 列建不出来。
|
// 前置条件:目标库必须已启用 pgvector 扩展,否则 vector(64) 列建不出来。
|
||||||
// 默认**不**导出扩展语句(可用 -with-extension 带上)。若目标容器由 scripts/pgvector 的
|
// 默认**不**导出扩展语句(可用 -with-extension 带上)。若目标容器由 scripts/pgvector 的
|
||||||
@ -105,15 +113,17 @@ func usage() {
|
|||||||
用法:
|
用法:
|
||||||
dbtool dump [-config <yml>] [-out <file>] [-with-extension] [-clean]
|
dbtool dump [-config <yml>] [-out <file>] [-with-extension] [-clean]
|
||||||
导出「结构 + 主键 / 索引 + 数据」为单个纯 SQL 文件
|
导出「结构 + 主键 / 索引 + 数据」为单个纯 SQL 文件
|
||||||
dbtool import [-config <yml>] [-in <file>]
|
dbtool import [-config <yml>] [-in <file>] [-clean]
|
||||||
把 dump 出来的 SQL 文件灌入目标库
|
把 dump 出来的 SQL 文件灌入目标库
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
|
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
|
||||||
-with-extension 在文件开头加 CREATE EXTENSION IF NOT EXISTS vector;
|
-with-extension(dump)在文件开头加 CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
默认不加,此时目标库须已启用 pgvector,否则 vector 列建不出来。
|
默认不加,此时目标库须已启用 pgvector,否则 vector 列建不出来。
|
||||||
-clean 在结构段前加 DROP TABLE IF EXISTS,使脚本可灌进**已有数据的库**
|
-clean 有两个位置,都会丢弃目标库中对应表的既有数据:
|
||||||
(目标库中这些表的既有数据会丢失)。不加时目标库必须是空库,否则主键冲突。
|
dump -clean 在结构段前写 DROP TABLE IF EXISTS,使文件可灌进已有数据的库
|
||||||
|
import -clean 导入前先 DROP public 下全部表,适合「只有旧文件、目标库状态不明」
|
||||||
|
不加 -clean 时要求目标库为空(或至少同名表为空),否则会主键冲突。
|
||||||
import 等价于 psql -v ON_ERROR_STOP=1 -f;整个脚本在一个隐式事务里执行,出错整体回滚。`)
|
import 等价于 psql -v ON_ERROR_STOP=1 -f;整个脚本在一个隐式事务里执行,出错整体回滚。`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,6 +241,7 @@ func runImport(args []string) {
|
|||||||
fs := flag.NewFlagSet("import", flag.ExitOnError)
|
fs := flag.NewFlagSet("import", flag.ExitOnError)
|
||||||
in := fs.String("in", "db_dump.sql", "导入文件路径")
|
in := fs.String("in", "db_dump.sql", "导入文件路径")
|
||||||
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
||||||
|
clean := fs.Bool("clean", false, "导入前先 DROP public 下全部表(覆盖式还原;目标库这些数据会丢失)")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
script, err := os.ReadFile(*in)
|
script, err := os.ReadFile(*in)
|
||||||
@ -244,6 +255,12 @@ func runImport(args []string) {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
|
if *clean {
|
||||||
|
// 与脚本拼成一段再执行:两者落在同一个隐式事务里,要么都成功、要么都回滚。
|
||||||
|
script = append([]byte(dropAllTablesSQL+"\n"), script...)
|
||||||
|
log.Printf("→ 导入前先清空 public 下全部表(-clean)")
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("→ 导入 %s(%d KB)-> %s ...", *in, len(script)/1024, dcfg.Addr())
|
log.Printf("→ 导入 %s(%d KB)-> %s ...", *in, len(script)/1024, dcfg.Addr())
|
||||||
if err := execScript(context.Background(), db, string(script)); err != nil {
|
if err := execScript(context.Background(), db, string(script)); err != nil {
|
||||||
log.Fatalf("✗ 导入失败(已整体回滚): %v", err)
|
log.Fatalf("✗ 导入失败(已整体回滚): %v", err)
|
||||||
@ -251,6 +268,24 @@ func runImport(args []string) {
|
|||||||
log.Printf("✓ 导入完成(%d KB)", len(script)/1024)
|
log.Printf("✓ 导入完成(%d KB)", len(script)/1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dropAllTablesSQL 删除 public 下全部基表(CASCADE 连带索引 / 约束 / 归属该表的序列)。
|
||||||
|
//
|
||||||
|
// 只删表,不动扩展:pgvector 的 vector 类型是 extension 对象,不属于任何表,因此
|
||||||
|
// DROP TABLE 之后扩展仍然可用,无需重新 CREATE EXTENSION。
|
||||||
|
const dropAllTablesSQL = `
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r record;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT c.relname FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE n.nspname = 'public' AND c.relkind = 'r'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('DROP TABLE IF EXISTS public.%I CASCADE', r.relname);
|
||||||
|
END LOOP;
|
||||||
|
END $$;`
|
||||||
|
|
||||||
// execScript 用 pgx 简单查询协议执行整段脚本。
|
// execScript 用 pgx 简单查询协议执行整段脚本。
|
||||||
//
|
//
|
||||||
// 必须走底层 *pgx.Conn:database/sql 的 Exec 用扩展协议,不支持一次发多条语句。
|
// 必须走底层 *pgx.Conn:database/sql 的 Exec 用扩展协议,不支持一次发多条语句。
|
||||||
@ -479,6 +514,10 @@ func writeConstraints(w *bufio.Writer, db *sql.DB, table string) error {
|
|||||||
if err := rows.Scan(&name, &def); err != nil {
|
if err := rows.Scan(&name, &def); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 先 DROP CONSTRAINT IF EXISTS 再 ADD,让这一段幂等:
|
||||||
|
// 目标库若已存在同名约束(例如早先被 AutoMigrate 建过、或导入中断留下的),
|
||||||
|
// 不先删就会报 relation "<name>" already exists —— 约束底层的索引名在 schema 内唯一。
|
||||||
|
fmt.Fprintf(w, "ALTER TABLE ONLY %s DROP CONSTRAINT IF EXISTS %s;\n", qname(table), qi(name))
|
||||||
fmt.Fprintf(w, "ALTER TABLE ONLY %s ADD CONSTRAINT %s %s;\n", qname(table), qi(name), def)
|
fmt.Fprintf(w, "ALTER TABLE ONLY %s ADD CONSTRAINT %s %s;\n", qname(table), qi(name), def)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
|
|||||||
@ -43,6 +43,12 @@ type StreetSnapDraftImage struct {
|
|||||||
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
|
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
|
||||||
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
|
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
|
||||||
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
|
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
|
||||||
|
|
||||||
|
// 主副图分组(列由 db/migrations/2026-09-21-01-street-main-detail.sql 添加):
|
||||||
|
// is_detail=0 为主图(parent_image_id=0);=1 为副图,parent_image_id 指向同草稿内主图的行 id。
|
||||||
|
// 存行 id 而非序号:序号会因重排 / 插入 / 删除而失效。
|
||||||
|
IsDetail uint8 `gorm:"column:is_detail;not null;default:0" json:"is_detail"`
|
||||||
|
ParentImageID uint32 `gorm:"column:parent_image_id;not null;default:0" json:"parent_image_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定草稿图片表名。
|
// TableName 指定草稿图片表名。
|
||||||
|
|||||||
@ -37,6 +37,18 @@ type ReviewRepository interface {
|
|||||||
SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error
|
SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error
|
||||||
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。
|
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。
|
||||||
SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error
|
SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error
|
||||||
|
// AttachStreetDraftImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
|
||||||
|
// mainImgID 必须属于同一草稿、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。
|
||||||
|
// 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID ——
|
||||||
|
// 避免出现「副图的副图」破坏不变量(见计划「实现裁决」第 1 条)。
|
||||||
|
// 幂等:已是 mainImgID 副图的行重复并入不报错。
|
||||||
|
AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error
|
||||||
|
// AttachPrevStreetDraftImage 把 imgID 并入「同草稿内 sort_order 紧邻的前一行所属组的主图」。
|
||||||
|
// imgID 是首行(没有上一行)时返回 ErrNotFound。
|
||||||
|
AttachPrevStreetDraftImage(ctx context.Context, draftID, imgID uint32) error
|
||||||
|
// DetachStreetDraftImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。
|
||||||
|
// 对主图调用是幂等成功(无副作用)。
|
||||||
|
DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error
|
||||||
// GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。
|
// GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。
|
||||||
GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error)
|
GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error)
|
||||||
UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error
|
UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
@ -599,3 +611,103 @@ func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, dr
|
|||||||
return "", ErrNotFound
|
return "", ErrNotFound
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 街拍草稿图片:主副图分组 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// AttachStreetDraftImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
|
||||||
|
//
|
||||||
|
// 事务内三步:1) 校验主图存在、未软删、本身是主图;2) 先「改挂孙辈」——被并入图原有的副图
|
||||||
|
// 改挂到新主图(避免副图的副图);3) 再把被并入图自身标为副图。第 2 步必须在第 3 步之前。
|
||||||
|
func (r *reviewRepository) AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error {
|
||||||
|
ids := make([]uint32, 0, len(imgIDs))
|
||||||
|
for _, id := range imgIDs {
|
||||||
|
if id != 0 && id != mainImgID {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mainImgID == 0 || len(imgIDs) == 0 {
|
||||||
|
// 主图无效,或传入集合本身就是空的(调用方连一张待并入图都没给)。
|
||||||
|
return ErrInvalidImageGroup
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
// 集合里只有主图自身:视为无操作成功(忽略),不报错也不改动主图。
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var main model.StreetSnapDraftImage
|
||||||
|
if err := tx.Where("id = ? AND draft_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, draftID).
|
||||||
|
First(&main).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrInvalidImageGroup
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := uint32(time.Now().Unix())
|
||||||
|
if err := tx.Model(&model.StreetSnapDraftImage{}).
|
||||||
|
Where("draft_id = ? AND is_deleted = 0 AND parent_image_id IN ?", draftID, ids).
|
||||||
|
Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&model.StreetSnapDraftImage{}).
|
||||||
|
Where("draft_id = ? AND is_deleted = 0 AND id IN ?", draftID, ids).
|
||||||
|
Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachPrevStreetDraftImage 把 imgID 并入「同草稿内 sort_order 紧邻的前一行所属组的主图」。
|
||||||
|
// 折回组主图是刻意的:连续「并入上一张」应链式归并到同一主图,而不是造出副图的副图。
|
||||||
|
func (r *reviewRepository) AttachPrevStreetDraftImage(ctx context.Context, draftID, imgID uint32) error {
|
||||||
|
var target uint32
|
||||||
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var cur model.StreetSnapDraftImage
|
||||||
|
if err := tx.Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID).First(&cur).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var prev model.StreetSnapDraftImage
|
||||||
|
err := tx.Where("draft_id = ? AND is_deleted = 0 AND (sort_order < ? OR (sort_order = ? AND id < ?))",
|
||||||
|
draftID, cur.SortOrder, cur.SortOrder, cur.ID).
|
||||||
|
Order("sort_order DESC, id DESC").First(&prev).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if prev.IsDetail == 1 && prev.ParentImageID != 0 {
|
||||||
|
target = prev.ParentImageID
|
||||||
|
} else {
|
||||||
|
target = prev.ID
|
||||||
|
}
|
||||||
|
if target == imgID {
|
||||||
|
return ErrInvalidImageGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
now := uint32(time.Now().Unix())
|
||||||
|
if err := tx.Model(&model.StreetSnapDraftImage{}).
|
||||||
|
Where("draft_id = ? AND is_deleted = 0 AND parent_image_id = ?", draftID, imgID).
|
||||||
|
Updates(map[string]any{"parent_image_id": target, "updated_at": now}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&model.StreetSnapDraftImage{}).
|
||||||
|
Where("id = ? AND draft_id = ?", imgID, draftID).
|
||||||
|
Updates(map[string]any{"is_detail": 1, "parent_image_id": target, "updated_at": now}).Error
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetachStreetDraftImage 把副图恢复为主图。对主图调用为幂等成功。
|
||||||
|
// 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。
|
||||||
|
func (r *reviewRepository) DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error {
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Model(&model.StreetSnapDraftImage{}).
|
||||||
|
Where("id = ? AND draft_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, draftID).
|
||||||
|
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|||||||
161
internal/repository/street_main_detail_integration_test.go
Normal file
161
internal/repository/street_main_detail_integration_test.go
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// 集成测试:街拍草稿主副图分组需要真实 PostgreSQL。
|
||||||
|
//
|
||||||
|
// 运行方式(先起库并确保结构已就绪,见 README「数据库」一节):
|
||||||
|
//
|
||||||
|
// docker compose -f scripts/pgvector/docker-compose.yml up -d
|
||||||
|
// go test -tags integration ./internal/repository/ -run TestStreetMainDetail -v
|
||||||
|
//
|
||||||
|
// 默认 `go test ./...` 不会编译本文件(build tag 隔离)。
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedStreetDraft 插入一条街拍草稿并返回其 id。
|
||||||
|
func seedStreetDraft(t *testing.T, repo ReviewRepository) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
db := repo.(*reviewRepository).db
|
||||||
|
d := model.StreetSnapDraft{Title: "主副图测试", Year: 2026, City: "TestCity", Status: model.DraftStatusPending}
|
||||||
|
if err := db.WithContext(ctx).Create(&d).Error; err != nil {
|
||||||
|
t.Fatalf("插入草稿失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM street_snap_draft_images WHERE draft_id = ?", d.ID)
|
||||||
|
db.Exec("DELETE FROM street_snap_drafts WHERE id = ?", d.ID)
|
||||||
|
})
|
||||||
|
return d.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedStreetDraftImage 插入一张草稿图并返回其行 id。
|
||||||
|
func seedStreetDraftImage(t *testing.T, repo ReviewRepository, draftID uint32, name string, order uint32) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
img := model.StreetSnapDraftImage{DraftID: draftID, Image: name + ".jpg", Name: name, SortOrder: order}
|
||||||
|
if err := repo.(*reviewRepository).db.WithContext(context.Background()).Create(&img).Error; err != nil {
|
||||||
|
t.Fatalf("插入草稿图失败: %v", err)
|
||||||
|
}
|
||||||
|
return img.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadStreetImages 取草稿的全部图(按 sort_order, id 升序),便于断言。
|
||||||
|
func reloadStreetImages(t *testing.T, repo ReviewRepository, draftID uint32) []model.StreetSnapDraftImage {
|
||||||
|
t.Helper()
|
||||||
|
imgs, err := repo.StreetDraftImages(context.Background(), draftID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读草稿图失败: %v", err)
|
||||||
|
}
|
||||||
|
return imgs
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStreetMainDetail 覆盖批量并入 / 并入上一张 / 拆出 / 跨草稿拦截四条主线。
|
||||||
|
func TestStreetMainDetail(t *testing.T) {
|
||||||
|
repo := NewReviewRepository(testDB(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
draftID := seedStreetDraft(t, repo)
|
||||||
|
m1 := seedStreetDraftImage(t, repo, draftID, "a", 1)
|
||||||
|
d2 := seedStreetDraftImage(t, repo, draftID, "b", 2)
|
||||||
|
d3 := seedStreetDraftImage(t, repo, draftID, "c", 3)
|
||||||
|
|
||||||
|
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil {
|
||||||
|
t.Fatalf("并入失败: %v", err)
|
||||||
|
}
|
||||||
|
for _, im := range reloadStreetImages(t, repo, draftID) {
|
||||||
|
switch im.ID {
|
||||||
|
case m1:
|
||||||
|
if im.IsDetail != 0 || im.ParentImageID != 0 {
|
||||||
|
t.Fatalf("主图应保持 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID)
|
||||||
|
}
|
||||||
|
case d2, d3:
|
||||||
|
if im.IsDetail != 1 || im.ParentImageID != m1 {
|
||||||
|
t.Fatalf("副图应指向 %d,实际 is_detail=%d parent=%d", m1, im.IsDetail, im.ParentImageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等:重复并入同一批不报错,结果不变。
|
||||||
|
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil {
|
||||||
|
t.Fatalf("重复并入应幂等,实际报错: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 传入主图自身:调用方过滤掉的语义由仓储兜住(id <> main 条件),不报错也不改动主图。
|
||||||
|
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{m1}); err != nil {
|
||||||
|
t.Fatalf("并入集合含主图自身应被忽略而非报错: %v", err)
|
||||||
|
}
|
||||||
|
for _, im := range reloadStreetImages(t, repo, draftID) {
|
||||||
|
if im.ID == m1 && (im.IsDetail != 0 || im.ParentImageID != 0) {
|
||||||
|
t.Fatalf("主图被自身并入后不应变化")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拆出:把 d3 恢复为主图。
|
||||||
|
if err := repo.DetachStreetDraftImage(ctx, draftID, d3); err != nil {
|
||||||
|
t.Fatalf("拆出失败: %v", err)
|
||||||
|
}
|
||||||
|
for _, im := range reloadStreetImages(t, repo, draftID) {
|
||||||
|
if im.ID == d3 && (im.IsDetail != 0 || im.ParentImageID != 0) {
|
||||||
|
t.Fatalf("拆出后应为 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := repo.DetachStreetDraftImage(ctx, draftID, m1); err != nil { // 对主图幂等成功
|
||||||
|
t.Fatalf("对主图拆出应幂等成功: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并入上一张:d3 的前一行是 d2(d2 已是 m1 的副图)→ 目标应折回 m1。
|
||||||
|
if err := repo.AttachPrevStreetDraftImage(ctx, draftID, d3); err != nil {
|
||||||
|
t.Fatalf("并入上一张失败: %v", err)
|
||||||
|
}
|
||||||
|
for _, im := range reloadStreetImages(t, repo, draftID) {
|
||||||
|
if im.ID == d3 && (im.IsDetail != 1 || im.ParentImageID != m1) {
|
||||||
|
t.Fatalf("并入上一张应折回主图 %d,实际 is_detail=%d parent=%d", m1, im.IsDetail, im.ParentImageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首行「并入上一张」应返回 ErrNotFound。
|
||||||
|
first := seedStreetDraftImage(t, repo, draftID, "z", 0)
|
||||||
|
if err := repo.AttachPrevStreetDraftImage(ctx, draftID, first); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("首行并入上一张应返回 ErrNotFound,实际 %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跨草稿拦截:空集合 + 跨草稿主图。
|
||||||
|
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, nil); !errors.Is(err, ErrInvalidImageGroup) {
|
||||||
|
t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err)
|
||||||
|
}
|
||||||
|
otherMain := seedStreetDraftImage(t, repo, seedStreetDraft(t, repo), "x", 1)
|
||||||
|
if err := repo.AttachStreetDraftImages(ctx, draftID, otherMain, []uint32{d2}); !errors.Is(err, ErrInvalidImageGroup) {
|
||||||
|
t.Fatalf("跨草稿主图应被拦截,实际 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStreetMainDetailReparentGrandchildren 并入一张自带副图的主图时,其原副图应改挂到新主图。
|
||||||
|
func TestStreetMainDetailReparentGrandchildren(t *testing.T) {
|
||||||
|
repo := NewReviewRepository(testDB(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
draftID := seedStreetDraft(t, repo)
|
||||||
|
m1 := seedStreetDraftImage(t, repo, draftID, "m1", 1)
|
||||||
|
m2 := seedStreetDraftImage(t, repo, draftID, "m2", 2)
|
||||||
|
child := seedStreetDraftImage(t, repo, draftID, "child", 3)
|
||||||
|
|
||||||
|
if err := repo.AttachStreetDraftImages(ctx, draftID, m2, []uint32{child}); err != nil {
|
||||||
|
t.Fatalf("预置副图失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{m2}); err != nil {
|
||||||
|
t.Fatalf("并入整组失败: %v", err)
|
||||||
|
}
|
||||||
|
for _, im := range reloadStreetImages(t, repo, draftID) {
|
||||||
|
if im.ID == m2 && (im.IsDetail != 1 || im.ParentImageID != m1) {
|
||||||
|
t.Fatalf("m2 应成为 m1 的副图,实际 %d/%d", im.IsDetail, im.ParentImageID)
|
||||||
|
}
|
||||||
|
if im.ID == child && (im.IsDetail != 1 || im.ParentImageID != m1) {
|
||||||
|
t.Fatalf("child 应改挂到 m1,实际 %d/%d", im.IsDetail, im.ParentImageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user