update
This commit is contained in:
174
internal/repository/admin_mixed_integration_test.go
Normal file
174
internal/repository/admin_mixed_integration_test.go
Normal file
@ -0,0 +1,174 @@
|
||||
//go:build integration
|
||||
|
||||
// 集成测试:文章管理混合列表必须跨 brand_runways / street_snaps 统一排序与过滤。
|
||||
//
|
||||
// 运行(库需先就绪,见 dedup_integration_test.go 顶部说明):
|
||||
//
|
||||
// go test -tags integration ./internal/repository/ -run TestAdminMixed -v
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// seedMixedRecords 各插一条走秀(已发布)与街拍(待审),返回它们的内部 id。
|
||||
// 用测试专用 city / season_code 隔离,避免与库里既有行撞唯一实体键。
|
||||
func seedMixedRecords(t *testing.T, db *gorm.DB) (runwayID, streetID uint32) {
|
||||
t.Helper()
|
||||
if err := db.Raw(`
|
||||
INSERT INTO brand_runways (brand_id, title_en, image_count, status, is_deleted, year, season_code, collection_type, created_at)
|
||||
VALUES (999999, 'AdminRepoTestRunway', 3, ?, 0, 2099, 'ZZZ', 'test', 1800000000)
|
||||
RETURNING id`, "published").Scan(&runwayID).Error; err != nil {
|
||||
t.Fatalf("插入走秀种子失败: %v", err)
|
||||
}
|
||||
if err := db.Raw(`
|
||||
INSERT INTO street_snaps (city, title, image_count, status, is_deleted, year, created_at)
|
||||
VALUES ('TestCityZZZ', 'AdminRepoTestStreet', 2, ?, 0, 2099, 1800000000)
|
||||
RETURNING id`, "pending").Scan(&streetID).Error; err != nil {
|
||||
t.Fatalf("插入街拍种子失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = db.Exec("DELETE FROM brand_runways WHERE id = ?", runwayID).Error
|
||||
_ = db.Exec("DELETE FROM street_snaps WHERE id = ?", streetID).Error
|
||||
})
|
||||
return runwayID, streetID
|
||||
}
|
||||
|
||||
// TestAdminMixedListFilterAndOrder 验证:
|
||||
// 1. 全部视图同时返回走秀与街拍;
|
||||
// 2. 待审核视图只返回 pending 的街拍,不含已发布的走秀;
|
||||
// 3. 排序为「pending 优先、其次 id 倒序」—— 任一 pending 都排在所有 published 之前。
|
||||
func TestAdminMixedListFilterAndOrder(t *testing.T) {
|
||||
db := testDB(t)
|
||||
runwayID, streetID := seedMixedRecords(t, db)
|
||||
|
||||
repo := NewAdminRepository(db)
|
||||
|
||||
all, total, err := repo.ListMixed(t.Context(), "", "", 1, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMixed 全部失败: %v", err)
|
||||
}
|
||||
if total < 2 {
|
||||
t.Fatalf("全部视图至少应含 2 条种子,实际 total=%d", total)
|
||||
}
|
||||
var sawRunway, sawStreet bool
|
||||
for _, c := range all {
|
||||
if c.Kind == "runway" && c.ID == runwayID {
|
||||
sawRunway = true
|
||||
}
|
||||
if c.Kind == "street" && c.ID == streetID {
|
||||
sawStreet = true
|
||||
}
|
||||
}
|
||||
if !sawRunway || !sawStreet {
|
||||
t.Fatalf("全部视图应同时含走秀与街拍: runway=%v street=%v", sawRunway, sawStreet)
|
||||
}
|
||||
|
||||
// 待审核过滤:街拍(pending) 出现,走秀(published) 不出现。
|
||||
pending, _, err := repo.ListMixed(t.Context(), "pending", "", 1, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMixed pending 失败: %v", err)
|
||||
}
|
||||
var pendingHasStreet, pendingHasRunway bool
|
||||
for _, c := range pending {
|
||||
if c.Kind == "street" && c.ID == streetID {
|
||||
pendingHasStreet = true
|
||||
}
|
||||
if c.Kind == "runway" && c.ID == runwayID {
|
||||
pendingHasRunway = true
|
||||
}
|
||||
}
|
||||
if !pendingHasStreet {
|
||||
t.Fatalf("待审核视图应含待审街拍")
|
||||
}
|
||||
if pendingHasRunway {
|
||||
t.Fatalf("待审核视图不应含已发布走秀")
|
||||
}
|
||||
|
||||
// 排序:任一 pending 必须排在所有 published 之前。
|
||||
var maxPendingIdx, minPublishedIdx = -1, 1 << 30
|
||||
for i, c := range all {
|
||||
if c.Status == "pending" {
|
||||
if i > maxPendingIdx {
|
||||
maxPendingIdx = i
|
||||
}
|
||||
} else if c.Status == "published" {
|
||||
if i < minPublishedIdx {
|
||||
minPublishedIdx = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if maxPendingIdx >= 0 && minPublishedIdx < 1<<30 && maxPendingIdx > minPublishedIdx {
|
||||
t.Fatalf("排序错误:存在 published 排在 pending 之前 (pending@%d, published@%d)", maxPendingIdx, minPublishedIdx)
|
||||
}
|
||||
|
||||
// CountMixed 与列表口径一致。
|
||||
if n, err := repo.CountMixed(t.Context(), "pending", ""); err != nil || n < 1 {
|
||||
t.Fatalf("CountMixed(pending) 应 >=1,实际 n=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMixedKindFilter 验证 kind 筛选:只取走秀 / 只取街拍 / 全部。
|
||||
func TestAdminMixedKindFilter(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
repo := NewAdminRepository(db)
|
||||
ctx := t.Context()
|
||||
|
||||
// 直接插入各一条,用独特 city / title 隔离唯一实体键。
|
||||
var runwayID, streetID uint32
|
||||
if err := db.Raw(`
|
||||
INSERT INTO brand_runways (brand_id, title_en, image_count, status, is_deleted, year, season_code, collection_type, created_at)
|
||||
VALUES (999999, 'KindFilterRunway', 1, 'published', 0, 2097, 'KFZ', 'test', 1800000000)
|
||||
RETURNING id`,
|
||||
).Scan(&runwayID).Error; err != nil {
|
||||
t.Fatalf("插入走秀种子失败: %v", err)
|
||||
}
|
||||
if err := db.Raw(`
|
||||
INSERT INTO street_snaps (city, title, image_count, status, is_deleted, year, created_at)
|
||||
VALUES ('KindFilterCity', 'KindFilterStreet', 1, 'published', 0, 2097, 1800000000)
|
||||
RETURNING id`,
|
||||
).Scan(&streetID).Error; err != nil {
|
||||
t.Fatalf("插入街拍种子失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = db.Exec("DELETE FROM brand_runways WHERE id = ?", runwayID).Error
|
||||
_ = db.Exec("DELETE FROM street_snaps WHERE id = ?", streetID).Error
|
||||
})
|
||||
|
||||
runwayOnly, _, err := repo.ListMixed(ctx, "", "runway", 1, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("kind=runway 失败: %v", err)
|
||||
}
|
||||
for _, c := range runwayOnly {
|
||||
if c.Kind != "runway" {
|
||||
t.Fatalf("kind=runway 不应含 %s", c.Kind)
|
||||
}
|
||||
}
|
||||
if len(runwayOnly) == 0 {
|
||||
t.Fatalf("kind=runway 至少应含 1 条")
|
||||
}
|
||||
|
||||
streetOnly, _, err := repo.ListMixed(ctx, "", "street", 1, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("kind=street 失败: %v", err)
|
||||
}
|
||||
for _, c := range streetOnly {
|
||||
if c.Kind != "street" {
|
||||
t.Fatalf("kind=street 不应含 %s", c.Kind)
|
||||
}
|
||||
}
|
||||
if len(streetOnly) == 0 {
|
||||
t.Fatalf("kind=street 至少应含 1 条")
|
||||
}
|
||||
|
||||
all, _, err := repo.ListMixed(ctx, "", "", 1, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("kind='' 失败: %v", err)
|
||||
}
|
||||
if len(all) != len(runwayOnly)+len(streetOnly) {
|
||||
t.Fatalf("全部应等于两类型之和:all=%d runway=%d street=%d", len(all), len(runwayOnly), len(streetOnly))
|
||||
}
|
||||
}
|
||||
172
internal/repository/admin_repository.go
Normal file
172
internal/repository/admin_repository.go
Normal file
@ -0,0 +1,172 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminRecordCard 是「文章管理」混合列表的统一卡片:跨 brand_runways 与 street_snaps
|
||||
// 两张表,字段取两者并集的最小可读子集(类型由 Kind 区分)。
|
||||
//
|
||||
// 为什么是 UNION 而非分别查两张表再在 Go 里拼:混合列表需要按(待审优先 + id 倒序)
|
||||
// 做跨表统一排序与分页,分别查两表再分页会得到错误的边界;一条 UNION 查询在数据库侧
|
||||
// 完成排序与 LIMIT/OFFSET,分页语义才正确,也避免把整张表拉进内存。
|
||||
type AdminRecordCard struct {
|
||||
Kind string // "runway" | "street"
|
||||
ID uint32 // 内部数值主键(供通过/驳回/批量接口使用)
|
||||
Title string
|
||||
Subtitle string // 走秀=品牌名;街拍=城市
|
||||
ImageCount int
|
||||
Status string // pending / published / rejected
|
||||
}
|
||||
|
||||
// DupStat 某记录的图片去重统计(仅统计 is_deleted=0 的存活行)。
|
||||
type DupStat struct {
|
||||
Total int // 存活图片总数(含主图与细节图)
|
||||
Dup int // is_duplicate=1 的张数
|
||||
}
|
||||
|
||||
// AdminRepository 文章管理页所需的跨表只读查询。
|
||||
type AdminRepository interface {
|
||||
// ListMixed 按状态 + 类型过滤(status 为空表示全部;kind 为空表示走秀+街拍,
|
||||
// "runway"/"street" 则只取对应一类),跨表统一排序后分页返回。
|
||||
// 排序:pending 优先,其次 id 倒序(最新待审排最前)。
|
||||
ListMixed(ctx context.Context, status, kind string, page, size int) ([]AdminRecordCard, int64, error)
|
||||
// CountMixed 统计某状态 + 类型下的记录总数(用于 tab 角标)。
|
||||
CountMixed(ctx context.Context, status, kind string) (int64, error)
|
||||
// DupRateByRecords 批量统计若干记录的图片去重情况(仅存活行)。
|
||||
// 返回 owner id → {Total 存活图片总数, Dup is_duplicate=1 张数};不存在的 id 不出现在 map 中。
|
||||
DupRateByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]DupStat, error)
|
||||
}
|
||||
|
||||
type adminRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAdminRepository 创建文章管理仓储。
|
||||
func NewAdminRepository(db *gorm.DB) AdminRepository {
|
||||
return &adminRepository{db: db}
|
||||
}
|
||||
|
||||
// legWhere 单条腿(带表别名)的过滤条件:is_deleted=0 是后台可见性的硬约束,
|
||||
// status 过滤可选。别名必须显式指定——两表 LEFT JOIN 时 is_deleted 会歧义。
|
||||
func legWhere(alias, status string) (string, []interface{}) {
|
||||
where := alias + ".is_deleted = 0"
|
||||
args := []interface{}{}
|
||||
if status != "" {
|
||||
where += " AND " + alias + ".status = ?"
|
||||
args = append(args, status)
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
|
||||
func (r *adminRepository) ListMixed(ctx context.Context, status, kind string, page, size int) ([]AdminRecordCard, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 24
|
||||
}
|
||||
rw, rargs := legWhere("r", status)
|
||||
sw, sargs := legWhere("s", status)
|
||||
|
||||
// kind 决定本次跨表 UNION 取哪几腿:""=两表都取,"street"=只取街拍,"runway"=只取走秀。
|
||||
var legs []string
|
||||
var args []interface{}
|
||||
if kind != "street" {
|
||||
legs = append(legs, `
|
||||
SELECT 'runway' AS kind, r.id, COALESCE(r.title_en, '') AS title, COALESCE(b.name_en, '') AS subtitle,
|
||||
CAST(r.image_count AS BIGINT) AS image_count, r.status
|
||||
FROM brand_runways r
|
||||
LEFT JOIN brands b ON b.id = r.brand_id
|
||||
WHERE `+rw)
|
||||
args = append(args, rargs...)
|
||||
}
|
||||
if kind != "runway" {
|
||||
legs = append(legs, `
|
||||
SELECT 'street' AS kind, s.id, COALESCE(s.title, '') AS title, COALESCE(s.city, '') AS subtitle,
|
||||
CAST(s.image_count AS BIGINT) AS image_count, s.status
|
||||
FROM street_snaps s
|
||||
WHERE `+sw)
|
||||
args = append(args, sargs...)
|
||||
}
|
||||
|
||||
q := `
|
||||
SELECT * FROM (` + strings.Join(legs, " UNION ALL ") + `) sub
|
||||
ORDER BY (status = 'pending') DESC, id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
listArgs := append(append([]interface{}{}, args...), size, (page-1)*size)
|
||||
|
||||
var cards []AdminRecordCard
|
||||
if err := r.db.WithContext(ctx).Raw(q, listArgs...).Scan(&cards).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
total, err := r.CountMixed(ctx, status, kind)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return cards, total, nil
|
||||
}
|
||||
|
||||
// DupRateByRecords 批量统计若干记录的图片去重情况。
|
||||
// kind=runway 查 brand_runway_images(外键 runway_id);kind=street 查 street_snap_images(外键 snap_id)。
|
||||
// 只统计 is_deleted=0 的存活行;命中阈值为 phash.DefaultThreshold(当前 4)。
|
||||
func (r *adminRepository) DupRateByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]DupStat, error) {
|
||||
out := make(map[uint32]DupStat)
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var table, fk string
|
||||
switch kind {
|
||||
case "runway":
|
||||
table, fk = "brand_runway_images", "runway_id"
|
||||
case "street":
|
||||
table, fk = "street_snap_images", "snap_id"
|
||||
default:
|
||||
return out, nil
|
||||
}
|
||||
type agg struct {
|
||||
Owner uint32 `gorm:"column:owner"`
|
||||
Total int `gorm:"column:total"`
|
||||
Dup int `gorm:"column:dup"`
|
||||
}
|
||||
var rows []agg
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table(table).
|
||||
Select(fk + " AS owner, COUNT(*) AS total, COALESCE(SUM(CASE WHEN is_duplicate = 1 THEN 1 ELSE 0 END), 0) AS dup").
|
||||
Where(fk+" IN ? AND is_deleted = 0", ids).
|
||||
Group(fk).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
out[row.Owner] = DupStat{Total: row.Total, Dup: row.Dup}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *adminRepository) CountMixed(ctx context.Context, status, kind string) (int64, error) {
|
||||
rw, rargs := legWhere("r", status)
|
||||
sw, sargs := legWhere("s", status)
|
||||
|
||||
var legs []string
|
||||
var args []interface{}
|
||||
if kind != "street" {
|
||||
legs = append(legs, `SELECT COUNT(*) AS c FROM brand_runways r WHERE `+rw)
|
||||
args = append(args, rargs...)
|
||||
}
|
||||
if kind != "runway" {
|
||||
legs = append(legs, `SELECT COUNT(*) AS c FROM street_snaps s WHERE `+sw)
|
||||
args = append(args, sargs...)
|
||||
}
|
||||
q := `
|
||||
SELECT COALESCE(SUM(c), 0) FROM (` + strings.Join(legs, " UNION ALL ") + `) t`
|
||||
var total int64
|
||||
if err := r.db.WithContext(ctx).Raw(q, args...).Scan(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
@ -331,21 +331,49 @@ func (r *articleRepository) UpdateRunway(ctx context.Context, id uint32, fields
|
||||
func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var img model.BrandRunwayImage
|
||||
err := tx.Select("runway_id").Where("id = ?", imageID).First(&img).Error
|
||||
err := tx.Select("runway_id, image").Where("id = ?", imageID).First(&img).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。
|
||||
var rw model.BrandRunway
|
||||
if err := tx.Select("cover").Where("id = ?", img.RunwayID).First(&rw).Error; 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 =
|
||||
if err := 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
|
||||
WHERE id = ?`, img.RunwayID, img.RunwayID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 被删图是封面:重指向另一张存活图(无存活图则清空),避免封面悬空。
|
||||
if rw.Cover == img.Image {
|
||||
cover := ""
|
||||
var survivor struct {
|
||||
Image string `gorm:"column:image"`
|
||||
}
|
||||
serr := tx.Raw(
|
||||
"SELECT image FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 ORDER BY id ASC LIMIT 1",
|
||||
img.RunwayID,
|
||||
).Scan(&survivor).Error
|
||||
if serr == nil {
|
||||
cover = survivor.Image
|
||||
} else if !errors.Is(serr, gorm.ErrRecordNotFound) {
|
||||
return serr
|
||||
}
|
||||
if err := tx.Model(&model.BrandRunway{}).Where("id = ?", img.RunwayID).
|
||||
Updates(map[string]any{"cover": cover, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@ -383,6 +411,13 @@ func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID
|
||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 全部图片软删:封面必然悬空(封面本就指向被删图之一),直接清空避免悬空指针。
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Where("id = ?", runwayID).
|
||||
Updates(map[string]any{"cover": "", "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
|
||||
@ -196,3 +196,133 @@ func TestEditorDeleteSnapImageRecomputesCount(t *testing.T) {
|
||||
t.Fatalf("删完所有图后 image_count 应为 0,实际 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// editorCover 读某记录的 cover 列(用于封面悬空防护断言)。
|
||||
func editorCover(t *testing.T, db *gorm.DB, table, idCol string, id uint32) string {
|
||||
t.Helper()
|
||||
var row struct{ Cover string `gorm:"column:cover"` }
|
||||
if err := db.Table(table).Select("cover").Where(idCol+" = ?", id).Scan(&row).Error; err != nil {
|
||||
t.Fatalf("读 %s.cover 失败: %v", table, err)
|
||||
}
|
||||
return row.Cover
|
||||
}
|
||||
|
||||
// TestEditorDeleteRunwayCoverRepointsWhenDeleted 删掉当前封面图时,封面应重指向另一张存活图。
|
||||
func TestEditorDeleteRunwayCoverRepointsWhenDeleted(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewArticleRepository(db)
|
||||
|
||||
id := seedEditorRunway(t, db, 2)
|
||||
seedEditorRunwayImage(t, db, id, "keep", 1, 0)
|
||||
drop := seedEditorRunwayImage(t, db, id, "drop", 2, 0)
|
||||
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "drop.jpg").Error; err != nil {
|
||||
t.Fatalf("设封面失败: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DeleteRunwayImage(ctx, drop); err != nil {
|
||||
t.Fatalf("删封面图失败: %v", err)
|
||||
}
|
||||
if got := editorCover(t, db, "brand_runways", "id", id); got != "keep.jpg" {
|
||||
t.Fatalf("删掉封面图后封面应重指向 keep.jpg,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditorDeleteRunwayCoverClearsWhenLastImage 删掉最后一张图(即封面)时,封面应清空而非悬空。
|
||||
func TestEditorDeleteRunwayCoverClearsWhenLastImage(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewArticleRepository(db)
|
||||
|
||||
id := seedEditorRunway(t, db, 1)
|
||||
only := seedEditorRunwayImage(t, db, id, "only", 1, 0)
|
||||
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "only.jpg").Error; err != nil {
|
||||
t.Fatalf("设封面失败: %v", err)
|
||||
}
|
||||
if err := repo.DeleteRunwayImage(ctx, only); err != nil {
|
||||
t.Fatalf("删最后一张图失败: %v", err)
|
||||
}
|
||||
if got := editorCover(t, db, "brand_runways", "id", id); got != "" {
|
||||
t.Fatalf("删掉最后一张图后封面应清空,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditorPurgeRunwayGalleryClearsCover 下架清空全部图片时,封面应清空(避免悬空指针)。
|
||||
func TestEditorPurgeRunwayGalleryClearsCover(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewArticleRepository(db)
|
||||
|
||||
id := seedEditorRunway(t, db, 2)
|
||||
seedEditorRunwayImage(t, db, id, "a", 1, 0)
|
||||
seedEditorRunwayImage(t, db, id, "b", 2, 0)
|
||||
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "a.jpg").Error; err != nil {
|
||||
t.Fatalf("设封面失败: %v", err)
|
||||
}
|
||||
if _, err := repo.SoftDeleteRunwayImages(ctx, id); err != nil {
|
||||
t.Fatalf("下架删图失败: %v", err)
|
||||
}
|
||||
if got := editorCover(t, db, "brand_runways", "id", id); got != "" {
|
||||
t.Fatalf("下架清空图片后封面应清空,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditorDeleteSnapCoverRepointsWhenDeleted 删掉当前封面图时,封面应重指向另一张存活图。
|
||||
func TestEditorDeleteSnapCoverRepointsWhenDeleted(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewStreetSnapRepository(db)
|
||||
|
||||
id := seedEditorSnap(t, db, 2)
|
||||
seedEditorSnapImage(t, db, id, "keep", 1, 0, 0)
|
||||
drop := seedEditorSnapImage(t, db, id, "drop", 2, 0, 0)
|
||||
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "drop.jpg").Error; err != nil {
|
||||
t.Fatalf("设封面失败: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DeleteSnapImage(ctx, drop); err != nil {
|
||||
t.Fatalf("删封面图失败: %v", err)
|
||||
}
|
||||
if got := editorCover(t, db, "street_snaps", "id", id); got != "keep.jpg" {
|
||||
t.Fatalf("删掉封面图后封面应重指向 keep.jpg,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditorDeleteSnapCoverClearsWhenLastImage 删掉最后一张图(即封面)时,封面应清空而非悬空。
|
||||
func TestEditorDeleteSnapCoverClearsWhenLastImage(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewStreetSnapRepository(db)
|
||||
|
||||
id := seedEditorSnap(t, db, 1)
|
||||
only := seedEditorSnapImage(t, db, id, "only", 1, 0, 0)
|
||||
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "only.jpg").Error; err != nil {
|
||||
t.Fatalf("设封面失败: %v", err)
|
||||
}
|
||||
if err := repo.DeleteSnapImage(ctx, only); err != nil {
|
||||
t.Fatalf("删最后一张图失败: %v", err)
|
||||
}
|
||||
if got := editorCover(t, db, "street_snaps", "id", id); got != "" {
|
||||
t.Fatalf("删掉最后一张图后封面应清空,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditorPurgeSnapGalleryClearsCover 下架清空全部图片时,封面应清空(避免悬空指针)。
|
||||
func TestEditorPurgeSnapGalleryClearsCover(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewStreetSnapRepository(db)
|
||||
|
||||
id := seedEditorSnap(t, db, 2)
|
||||
seedEditorSnapImage(t, db, id, "a", 1, 0, 0)
|
||||
seedEditorSnapImage(t, db, id, "b", 2, 0, 0)
|
||||
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "a.jpg").Error; err != nil {
|
||||
t.Fatalf("设封面失败: %v", err)
|
||||
}
|
||||
if _, err := repo.SoftDeleteSnapImages(ctx, id); err != nil {
|
||||
t.Fatalf("下架删图失败: %v", err)
|
||||
}
|
||||
if got := editorCover(t, db, "street_snaps", "id", id); got != "" {
|
||||
t.Fatalf("下架清空图片后封面应清空,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/model"
|
||||
@ -42,6 +43,8 @@ type IngestRepository interface {
|
||||
// ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。
|
||||
// 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。
|
||||
ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error
|
||||
// RepairRunwayDetailParents 入库后按 sort_order 回填走秀细节图的 parent_image_id(细节图挂到最近主图)。
|
||||
RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error
|
||||
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
|
||||
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
|
||||
StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found bool, err error)
|
||||
@ -335,11 +338,49 @@ func (r *ingestRepository) ReuseRejectedRunway(ctx context.Context, id uint32, r
|
||||
})
|
||||
}
|
||||
|
||||
// RepairRunwayDetailParents 入库后按 sort_order 顺序回填走秀细节图的 parent_image_id:
|
||||
// 每张 is_detail=1 的图挂到它之前最近的一张 is_detail=0(主图)之下。
|
||||
// 入库时细节图紧随其主图之后(同一 Look 先主图后细节),故「最近主图」即其归属主图。
|
||||
func (r *ingestRepository) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
|
||||
var imgs []model.BrandRunwayImage
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&imgs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
lastMain := uint32(0)
|
||||
type fix struct{ id, parent uint32 }
|
||||
fixes := make([]fix, 0)
|
||||
for _, im := range imgs {
|
||||
if im.IsDetail == 0 {
|
||||
lastMain = im.ID
|
||||
continue
|
||||
}
|
||||
if im.IsDetail == 1 && im.ParentImageID == 0 && lastMain != 0 {
|
||||
fixes = append(fixes, fix{im.ID, lastMain})
|
||||
}
|
||||
}
|
||||
for _, f := range fixes {
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayImage{}).
|
||||
Where("id = ? AND runway_id = ? AND is_deleted = 0", f.id, runwayID).
|
||||
Update("parent_image_id", f.parent).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
|
||||
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致:唯一索引里 NULL 互不冲突,
|
||||
// 若这里写成裸 title,查重口径会比索引更严,导致重爬同城同年(title 为 NULL 的历史行)时误判未命中、
|
||||
// 进而插入一行注定被索引拒绝的记录。
|
||||
//
|
||||
// 入参 title 会先经 NormalizeStreetTitle 归一化:与落库口径(processStreet 同样归一化)保持一致,
|
||||
// 否则「空白 / 多空格」微调会被判为新实体,同城同年多出重复行(见设计规格 §12.4)。
|
||||
func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (uint32, string, bool, error) {
|
||||
title = NormalizeStreetTitle(title)
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
Status string `gorm:"column:status"`
|
||||
@ -359,6 +400,17 @@ func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city strin
|
||||
return row.ID, row.Status, true, nil
|
||||
}
|
||||
|
||||
// NormalizeStreetTitle 街拍实体键标题归一化:去除首尾空白,并把内部任意空白(空格 / 制表 / 换行)
|
||||
// 连续段折叠为单个空格。
|
||||
//
|
||||
// 为什么需要:实体键含 title((city, year, COALESCE(title, ''))),同一专题标题只做空白 / 大小写 / 措辞
|
||||
// 微调会被判为新实体(设计规格 §12.4)。本函数消除「纯空白」这一类可机械归一的差异,避免重复行。
|
||||
// 措辞差异无法机械归一,仍按不同实体处理(符合预期)。调用方在落库与查重两侧都必须用同一函数,
|
||||
// 才能保证索引(COALESCE(title, ''))与查重口径逐字一致。
|
||||
func NormalizeStreetTitle(title string) string {
|
||||
return strings.Join(strings.Fields(title), " ")
|
||||
}
|
||||
|
||||
func (r *ingestRepository) CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error) {
|
||||
now := uint32(time.Now().Unix())
|
||||
snap.CreatedAt, snap.UpdatedAt = now, now
|
||||
|
||||
@ -22,7 +22,7 @@ func newRunwayForIngest(jobID uint32, brandID uint32, seasonCode, collectionType
|
||||
}
|
||||
|
||||
func newRunwayImages(cover string) []model.BrandRunwayImage {
|
||||
return []model.BrandRunwayImage{{BrandID: 1, Image: cover, Name: "Look 1", SortOrder: 1, LookIndex: 1}}
|
||||
return []model.BrandRunwayImage{{BrandID: 1, Image: cover, Name: "Look 1", SortOrder: 1}}
|
||||
}
|
||||
|
||||
// TestIngestCreatesPendingRecord 新建分支:实体键不存在 → 写入正式表且 status=pending,图片落到正式图片表。
|
||||
|
||||
66
internal/repository/public_view_columns_integration_test.go
Normal file
66
internal/repository/public_view_columns_integration_test.go
Normal file
@ -0,0 +1,66 @@
|
||||
//go:build integration
|
||||
|
||||
// 集成测试:公开只读视图(public_brand_runways / public_street_snaps)不得暴露审核/溯源列
|
||||
// (job_id / reviewer / reject_reason)。设计规格 I2:视图列集已显式枚举,刻意排除这些列,
|
||||
// 使基表加列时敏感列不会无意泄露到公开读路径。
|
||||
//
|
||||
// 运行:go test -tags integration ./internal/repository/ -run TestPublicViewsExcludeSensitiveColumns -v
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPublicViewsExcludeSensitiveColumns 断言两个主表公开视图不含敏感列。
|
||||
func TestPublicViewsExcludeSensitiveColumns(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
|
||||
type v struct {
|
||||
view, col string
|
||||
}
|
||||
cases := []v{}
|
||||
for _, view := range []string{"public_brand_runways", "public_street_snaps"} {
|
||||
for _, col := range []string{"job_id", "reviewer", "reject_reason"} {
|
||||
cases = append(cases, v{view, col})
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
var cnt int64
|
||||
if err := db.Raw(
|
||||
`SELECT count(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,
|
||||
c.view, c.col,
|
||||
).Scan(&cnt).Error; err != nil {
|
||||
t.Fatalf("查 information_schema 失败: %v", err)
|
||||
}
|
||||
if cnt != 0 {
|
||||
t.Errorf("公开视图 %s 不应暴露敏感列 %s", c.view, c.col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicViewsKeepNeededColumns 断言公开视图仍含对外必需的列(回归保护,避免收窄时误删)。
|
||||
func TestPublicViewsKeepNeededColumns(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
|
||||
keep := map[string][]string{
|
||||
"public_brand_runways": {"id", "brand_id", "title_en", "cover", "image_count", "year", "season_code", "status"},
|
||||
"public_street_snaps": {"id", "title", "title_cn", "city", "cover", "image_count", "year", "status"},
|
||||
}
|
||||
for view, cols := range keep {
|
||||
for _, col := range cols {
|
||||
var cnt int64
|
||||
if err := db.Raw(
|
||||
`SELECT count(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,
|
||||
view, col,
|
||||
).Scan(&cnt).Error; err != nil {
|
||||
t.Fatalf("查 information_schema 失败: %v", err)
|
||||
}
|
||||
if cnt != 1 {
|
||||
t.Errorf("公开视图 %s 必须含列 %s(收窄时误删)", view, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -46,6 +46,11 @@ type ReviewRepository interface {
|
||||
AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error
|
||||
// DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。
|
||||
DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error
|
||||
// AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。
|
||||
// 与 AttachStreetSnapImages 同构(按 parent_image_id 分组):先改挂孙辈,再把被并入图标为副图。
|
||||
AttachRunwayImages(ctx context.Context, runwayID, mainImgID uint32, imgIDs []uint32) error
|
||||
// DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。
|
||||
DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error
|
||||
// GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
|
||||
GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error)
|
||||
// CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。
|
||||
@ -412,3 +417,89 @@ func (r *reviewRepository) DetachStreetSnapImage(ctx context.Context, snapID, im
|
||||
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
|
||||
Error
|
||||
}
|
||||
|
||||
// AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。
|
||||
//
|
||||
// 与 AttachStreetSnapImages 同构(均按 parent_image_id 分组):事务内先「改挂孙辈」
|
||||
// (被并入图原有的副图 parent_image_id IN ids 改挂到新主图),再把被并入图自身标为副图并指向新主图。
|
||||
func (r *reviewRepository) AttachRunwayImages(ctx context.Context, runwayID, 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.BrandRunwayImage
|
||||
if err := tx.Where("id = ? AND runway_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, runwayID).
|
||||
First(&main).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrInvalidImageGroup
|
||||
}
|
||||
return err
|
||||
}
|
||||
now := uint32(time.Now().Unix())
|
||||
// 先改挂孙辈:被并入图原有的副图(parent_image_id IN ids)改挂到新主图。
|
||||
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||
Where("runway_id = ? AND is_deleted = 0 AND parent_image_id IN ?", runwayID, ids).
|
||||
Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 再把被并入图自身标为副图并指向新主图。
|
||||
return tx.Model(&model.BrandRunwayImage{}).
|
||||
Where("runway_id = ? AND is_deleted = 0 AND id IN ?", runwayID, ids).
|
||||
Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。
|
||||
// 同主图下其余明细图仍挂在该主图之下,不变量成立。
|
||||
func (r *reviewRepository) DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayImage{}).
|
||||
Where("id = ? AND runway_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, runwayID).
|
||||
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
|
||||
Error
|
||||
}
|
||||
|
||||
// RepairRunwayDetailParents 按 sort_order 顺序回填走秀细节图的 parent_image_id:
|
||||
// 每张 is_detail=1 的图挂到它之前最近的一张 is_detail=0(主图)之下。
|
||||
// 入库时细节图紧随其主图之后(同一 Look 先主图后细节),故「最近主图」即其归属主图。
|
||||
// 供入库后调用,使新入库的走秀自动成组,无需人工合并。
|
||||
func (r *reviewRepository) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
|
||||
var imgs []model.BrandRunwayImage
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&imgs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
lastMain := uint32(0)
|
||||
type fix struct{ id, parent uint32 }
|
||||
fixes := make([]fix, 0)
|
||||
for _, im := range imgs {
|
||||
if im.IsDetail == 0 {
|
||||
lastMain = im.ID
|
||||
continue
|
||||
}
|
||||
if im.IsDetail == 1 && im.ParentImageID == 0 && lastMain != 0 {
|
||||
fixes = append(fixes, fix{im.ID, lastMain})
|
||||
}
|
||||
}
|
||||
for _, f := range fixes {
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayImage{}).
|
||||
Where("id = ? AND runway_id = ? AND is_deleted = 0", f.id, runwayID).
|
||||
Update("parent_image_id", f.parent).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -223,10 +223,11 @@ func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.
|
||||
|
||||
// snapEditable 后台可编辑的街拍字段白名单(键即列名)。
|
||||
var snapEditable = map[string]bool{
|
||||
"title": true,
|
||||
"year": true,
|
||||
"city": true,
|
||||
"cover": true,
|
||||
"title": true,
|
||||
"title_cn": true,
|
||||
"year": true,
|
||||
"city": true,
|
||||
"cover": true,
|
||||
}
|
||||
|
||||
func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
|
||||
@ -274,21 +275,49 @@ func (r *streetSnapRepository) UpdateSnap(ctx context.Context, id uint32, fields
|
||||
func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var img model.StreetSnapImage
|
||||
err := tx.Select("snap_id").Where("id = ?", imageID).First(&img).Error
|
||||
err := tx.Select("snap_id, image").Where("id = ?", imageID).First(&img).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。
|
||||
var snap model.StreetSnap
|
||||
if err := tx.Select("cover").Where("id = ?", img.SnapID).First(&snap).Error; 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 =
|
||||
if err := 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
|
||||
WHERE id = ?`, img.SnapID, img.SnapID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 被删图是封面:重指向另一张存活图(无存活图则清空),避免封面悬空。
|
||||
if snap.Cover == img.Image {
|
||||
cover := ""
|
||||
var survivor struct {
|
||||
Image string `gorm:"column:image"`
|
||||
}
|
||||
serr := tx.Raw(
|
||||
"SELECT image FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0 ORDER BY id ASC LIMIT 1",
|
||||
img.SnapID,
|
||||
).Scan(&survivor).Error
|
||||
if serr == nil {
|
||||
cover = survivor.Image
|
||||
} else if !errors.Is(serr, gorm.ErrRecordNotFound) {
|
||||
return serr
|
||||
}
|
||||
if err := tx.Model(&model.StreetSnap{}).Where("id = ?", img.SnapID).
|
||||
Updates(map[string]any{"cover": cover, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@ -326,6 +355,13 @@ func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID
|
||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 全部图片软删:封面必然悬空(封面本就指向被删图之一),直接清空避免悬空指针。
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnap{}).
|
||||
Where("id = ?", snapID).
|
||||
Updates(map[string]any{"cover": "", "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/model"
|
||||
)
|
||||
|
||||
// TestStreetTitleNormalizationUnifiesEntity 验证街拍实体键标题归一化:
|
||||
// 落库标题带多余空白(模拟爬虫原始上报)经 NormalizeStreetTitle 折叠后写入,
|
||||
// 查重侧用未归一化的原始标题(多空格)也应命中同一实体,不会被判成新实体。
|
||||
//
|
||||
// 设计规格 §12.4:纯空白微调的同一专题若被当作新实体,会多出重复行。
|
||||
func TestStreetTitleNormalizationUnifiesEntity(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
applyMigration(t, db, "2026-09-22-03-entity-key-unique.sql")
|
||||
applyMigration(t, db, "2026-09-22-04-street-entity-key-title.sql")
|
||||
|
||||
repo := NewIngestRepository(db)
|
||||
ctx := context.Background()
|
||||
city, year := "NormCity", uint16(2099)
|
||||
|
||||
// 落库口径:与 processStreet 一致,先归一化再写。
|
||||
stored := NormalizeStreetTitle("Norm Show 2026")
|
||||
id, err := repo.CreateStreetSnapWithImages(ctx, &model.StreetSnap{
|
||||
JobID: 1, Title: stored, Year: year, City: city, Status: model.StatusPending,
|
||||
}, []model.StreetSnapImage{{Image: "norm.jpg", SortOrder: 1}})
|
||||
if err != nil {
|
||||
t.Fatalf("插入归一化标题失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", id)
|
||||
db.Exec("DELETE FROM street_snaps WHERE id = ?", id)
|
||||
})
|
||||
|
||||
// 查重口径:用未归一化的原始标题(多空格)→ 必须命中已落库行。
|
||||
hid, _, found, err := repo.StreetSnapEntityState(ctx, city, year, "Norm Show 2026")
|
||||
if err != nil {
|
||||
t.Fatalf("查重出错: %v", err)
|
||||
}
|
||||
if !found || hid != id {
|
||||
t.Fatalf("归一化后应命中同一实体 id=%d,实际 found=%v hid=%d", id, found, hid)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user