update backend

This commit is contained in:
toom1996
2026-09-20 01:06:13 +08:00
parent f7ae917603
commit b99dc3dca1
115 changed files with 24732 additions and 10842 deletions

View File

@ -30,7 +30,7 @@ type ArticleRepository interface {
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
// DeleteRunwayImage 软删单张走秀图片。
DeleteRunwayImage(ctx context.Context, imageID uint32) error
// GetRunwayImageKey 取单张走秀图片的七牛 key(删除前清理用);不存在返回空串。
// GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。
GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error)
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。
SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error)
@ -52,25 +52,25 @@ func NewArticleRepository(db *gorm.DB) ArticleRepository {
// selectColumns 列表查询的投影列:只取对外需要的字段,不做 SELECT *。
// 注意:year / season / season_code / collection_type / created_at 等仅用于排序,不 SELECT 到响应里
// (前端按年份/季节筛选是走查询参数,响应无需回带这些元数据)。
const articleListColumns = `brand_runway.id, brand_runway.brand_id,
brand_runway.title_en, brand_runway.title_cn,
brand_runway.cover, brand_runway.image_count,
const articleListColumns = `brand_runways.id, brand_runways.brand_id,
brand_runways.title_en, brand_runways.title_cn,
brand_runways.cover, brand_runways.image_count,
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
const articleDetailColumns = `brand_runway.id, brand_runway.title_en, brand_runway.title_cn,
brand_runway.description_en, brand_runway.description_cn,
brand_runway.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
const articleDetailColumns = `brand_runways.id, brand_runways.title_en, brand_runways.title_cn,
brand_runways.description_en, brand_runways.description_cn,
brand_runways.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
// articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year /
// collection_type / season_code 等管理字段(前端列表不展示这些)。
const articleAdminColumns = `brand_runway.id, brand_runway.brand_id,
brand_runway.title_en, brand_runway.title_cn,
brand_runway.cover, brand_runway.image_count, brand_runway.year,
brand_runway.collection_type, brand_runway.season, brand_runway.season_code,
const articleAdminColumns = `brand_runways.id, brand_runways.brand_id,
brand_runways.title_en, brand_runways.title_cn,
brand_runways.cover, brand_runways.image_count, brand_runways.year,
brand_runways.collection_type, brand_runways.season, brand_runways.season_code,
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
// brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。
const brandJoin = "LEFT JOIN brand b ON b.id = brand_runway.brand_id AND b.is_deleted = 0"
const brandJoin = "LEFT JOIN brands b ON b.id = brand_runways.brand_id AND b.is_deleted = 0"
// filterScope 把查询条件编译为 GORM Scope。
//
@ -78,18 +78,18 @@ const brandJoin = "LEFT JOIN brand b ON b.id = brand_runway.brand_id AND b.is_de
// 会带上残留的 Statement 状态,Scope 每次作用于全新查询,杜绝这类隐患。
func filterScope(q dto.ArticleQuery) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
db = db.Where("brand_runway.is_deleted = 0")
db = db.Where("brand_runways.is_deleted = 0")
// 按品牌过滤来自列表接口的 ?brand_id 查询参数(q.BrandIDs 由 handler 解码注入)。
db = whereMulti(db, "brand_runway.brand_id", toAnySlice(q.BrandIDs))
db = whereMulti(db, "brand_runways.brand_id", toAnySlice(q.BrandIDs))
if q.Collection != "" {
db = db.Where("brand_runway.collection_type = ?", q.Collection)
db = db.Where("brand_runways.collection_type = ?", q.Collection)
}
if q.Season != "" {
db = db.Where("brand_runway.season = ?", q.Season)
db = db.Where("brand_runways.season = ?", q.Season)
}
if q.Year != 0 {
db = db.Where("brand_runway.year = ?", q.Year)
db = db.Where("brand_runways.year = ?", q.Year)
}
return db
}
@ -126,14 +126,14 @@ func toAnySlice[T any](in []T) []any {
// newest(默认):按走秀真实新旧排序 —— created_at 仅是导入时间,不能代表走秀新旧,
// 故用 year DESC + season_code 优先级(FW>PF>SS>RES)+ id 兜底,与首页「最新走秀」口径一致。
func orderBy(sort string) string {
seasonPrio := `CASE brand_runway.season_code WHEN 'FW' THEN 4 WHEN 'PF' THEN 3 WHEN 'SS' THEN 2 WHEN 'RES' THEN 1 ELSE 0 END`
seasonPrio := `CASE brand_runways.season_code WHEN 'FW' THEN 4 WHEN 'PF' THEN 3 WHEN 'SS' THEN 2 WHEN 'RES' THEN 1 ELSE 0 END`
switch sort {
case "year_asc":
return "brand_runway.year ASC, brand_runway.id ASC"
return "brand_runways.year ASC, brand_runways.id ASC"
case "year_desc":
return "brand_runway.year DESC, brand_runway.id DESC"
return "brand_runways.year DESC, brand_runways.id DESC"
default: // newest
return "brand_runway.year DESC, " + seasonPrio + " DESC, brand_runway.id DESC"
return "brand_runways.year DESC, " + seasonPrio + " DESC, brand_runways.id DESC"
}
}
@ -202,7 +202,7 @@ func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.Run
Model(&model.BrandRunway{}).
Select(articleDetailColumns).
Joins(brandJoin).
Where("brand_runway.id = ? AND brand_runway.is_deleted = 0", id).
Where("brand_runways.id = ? AND brand_runways.is_deleted = 0", id).
First(&row).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@ -309,7 +309,7 @@ func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint3
}
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用)。
// 先取非删除图片的 key 列表返回给调用方(用于后续七牛孤儿清理),再统一置 is_deleted=1。
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error) {
var imgs []model.BrandRunwayImage
if err := r.db.WithContext(ctx).

View File

@ -176,7 +176,7 @@ func (r *brandRepository) FeaturedIDs(ctx context.Context, metric string, limit
return ids, nil
}
// ListByIDs 按 id 集合取品牌行;MySQL IN 查询不保证返回顺序,调用方自行重排。
// ListByIDs 按 id 集合取品牌行;IN 查询不保证返回顺序,调用方自行重排。
func (r *brandRepository) ListByIDs(ctx context.Context, ids []uint32) ([]model.Brand, error) {
if len(ids) == 0 {
return []model.Brand{}, nil
@ -281,7 +281,7 @@ WITH latest_per_brand AS (
END DESC,
id DESC
) AS rn
FROM brand_runway
FROM brand_runways
WHERE is_deleted = 0 AND brand_id IN (?)
)
SELECT id, brand_id, cover, title_en, title_cn

View File

@ -0,0 +1,96 @@
//go:build integration
// 集成测试:去重核心查询需要真实 PostgreSQL + pgvector 环境。
//
// 运行方式(先起库,见 scripts/pgvector):
//
// docker compose -f scripts/pgvector/docker-compose.yml up -d
// go test -tags integration ./internal/repository/ -run TestDedup -v
//
// 默认 `go test ./...` 不会编译本文件(build tag 隔离),CI 无库环境不受影响。
package repository
import (
"context"
"database/sql"
"testing"
"fashionapi/internal/config"
"fashionapi/internal/database"
"fashionapi/internal/model"
"fashionapi/internal/pkg/phash"
"gorm.io/gorm"
)
// testDB 连真实库并完成 AutoMigrate + 去重 schema 初始化。
func testDB(t *testing.T) *gorm.DB {
t.Helper()
cfg, err := config.Load("")
if err != nil {
t.Fatalf("加载配置失败: %v", err)
}
db, err := database.New(cfg.Database)
if err != nil {
t.Fatalf("连接数据库失败: %v", err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("AutoMigrate 失败: %v", err)
}
if err := database.EnsureDedupSchema(db); err != nil {
t.Fatalf("去重 schema 失败: %v", err)
}
t.Cleanup(func() { _ = database.Close(db) })
return db
}
// insertRunwayImage 直接插入一条走秀图片行(无外键约束,runway_id / brand_id 可填占位值)。
func insertRunwayImage(t *testing.T, db *gorm.DB, image, phashBits string) {
t.Helper()
var ph sql.NullString
if phashBits != "" {
ph = sql.NullString{String: phashBits, Valid: true}
}
row := model.BrandRunwayImage{
RunwayID: 1,
BrandID: 1,
Image: image,
Name: "look",
SortOrder: 1,
Phash: ph,
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("插入图片失败: %v", err)
}
t.Cleanup(func() {
db.Where("image = ?", image).Delete(&model.BrandRunwayImage{})
})
}
// TestDedupNearDuplicate 近重复:dHash 汉明距离 ≤ 阈值应被 FindNearDuplicateImage 命中。
func TestDedupNearDuplicate(t *testing.T) {
db := testDB(t)
repo := NewIngestRepository(db)
ctx := context.Background()
// 插入一张已有图片,phash 为 64 维全 1 向量。
insertRunwayImage(t, db, "runway/near-dup.jpg", phash.ToVectorBits(^uint64(0)))
tables := []string{"brand_runway_images"}
// 探测向量:仅第 0 位为 0、其余为 1 → 与全 1 向量汉明距离 = 1,应命中。
probe := ^uint64(0) &^ 1
_, found, err := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(probe), phash.DefaultThreshold)
if err != nil {
t.Fatalf("FindNearDuplicateImage 出错: %v", err)
}
if !found {
t.Fatalf("汉明距离 1 的近重复应被命中")
}
// 全 0 向量:与全 1 向量汉明距离 = 64,远超阈值,不应命中。
_, found2, _ := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(0), phash.DefaultThreshold)
if found2 {
t.Fatalf("汉明距离 64 的图不应被误判为近重复")
}
}

View File

@ -47,7 +47,7 @@ func (r *historyRepository) Upsert(ctx context.Context, h *model.History) error
if err != nil {
return err
}
// 超出用户上限则 FIFO 裁剪最旧若干(子查询双嵌套规避 MySQL「同表不可直接子查删除」限制)。
// 超出用户上限则 FIFO 裁剪最旧若干(子查询双嵌套规避数据库「同表不可直接子查删除」限制)。
var n int64
if err := r.db.WithContext(ctx).Model(&model.History{}).Where("user_id = ?", h.UserID).Count(&n).Error; err != nil {
return err

View File

@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"math"
"time"
"fashionapi/internal/model"
@ -11,13 +12,13 @@ import (
)
// IngestRepository 爬虫入库管线专属仓储:任务队列(ingest_jobs)+ nonce 防重放
// (ingest_nces)+ 走秀正式表写入(brand_runway / brand_runway_images)。
// (ingest_nonces)+ 走秀正式表写入(brand_runways / brand_runway_images)。
//
// 去重由 worker 按「实体键」完成:走秀 = brand_id + season_code + collection_type,
// 街拍 = city + year(与 review_repository 的 SaveRunwayFromDraft / SaveStreetSnapFromDraft
// 晋升时的幂等键一致),因此一个秀/街拍只会在正式表留一行,多来源图片在晋升阶段聚合。
//
// 入队与领取用同一张 ingest_jobs 表,领取靠 MySQL 的 FOR UPDATE SKIP LOCKED
// 入队与领取用同一张 ingest_jobs 表,领取靠 PostgreSQL 的 FOR UPDATE SKIP LOCKED
// 实现「多 worker 安全并发」——同一条任务只会被一个 worker 拿到,其它 worker 跳过它。
type IngestRepository interface {
// Enqueue 写入一条待处理任务(payload 为原始 JSON)。
@ -44,8 +45,6 @@ type IngestRepository interface {
CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error)
// CreateStreetSnapDraftImages 批量插入街拍草稿图片行。
CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error
// ImageExistsBySha1 在给定图片表中是否已存在相同内容哈希(精确重复);用于入库前跳过该行。
ImageExistsBySha1(ctx context.Context, tables []string, sha1 string) (bool, error)
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,返回命中行 id(仅取最近一条)。
FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (dupID uint32, found bool, err error)
// ListJobs 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。
@ -54,8 +53,8 @@ type IngestRepository interface {
CountJobs(ctx context.Context) (int64, error)
// RetryJob 把一条 failed 任务重置回 pending,清 last_error/locked_at,等待 worker 重新处理。
RetryJob(ctx context.Context, id uint32) error
// EnqueueMediaCleanup 写入一条「清理七牛孤儿图」任务(payload 为待清理 key 的 JSON)。
// 由删除图集的服务调用,把同步的七牛删除改为异步队列,避免阻塞删除请求。
// EnqueueMediaCleanup 写入一条「清理S4孤儿图」任务(payload 为待清理 key 的 JSON)。
// 由删除图集的服务调用,把同步的S4删除改为异步队列,避免阻塞删除请求。
EnqueueMediaCleanup(ctx context.Context, payload string) error
// ScheduleRetry 失败时调用:attempts+1,未达上限则退避后重置 pending,达上限则置 failed。
// 用于临时失败(网络抖动 / 单图下载失败)的自动重试,区别于永久失败(payload 解析错等)直接 MarkFailed。
@ -118,7 +117,7 @@ func (r *ingestRepository) Claim(ctx context.Context, limit int) ([]model.Ingest
}
// 2) 领取 pending 且已到重试时间的任务(含刚回收的 + 新入队的 + 退避已到期的)。
// 优先处理 media_cleanup(删除图集时异步清理七牛),避免被大批 crawl 任务排到后面、清理迟迟不触发。
// 优先处理 media_cleanup(删除图集时异步清理S4),避免被大批 crawl 任务排到后面、清理迟迟不触发。
var ids []uint32
if err := tx.Raw(
"SELECT id FROM ingest_jobs WHERE status = ? AND next_attempt_at <= ? "+
@ -209,7 +208,7 @@ func (r *ingestRepository) ScheduleRetry(ctx context.Context, id uint32, errMsg
}).Error
}
// EnqueueMediaCleanup 写入一条「清理七牛孤儿图」任务(kind=media_cleanup),
// EnqueueMediaCleanup 写入一条「清理S4孤儿图」任务(kind=media_cleanup),
// payload 为待清理 key 的 JSON,由 worker 的 processMediaCleanup 按引用计数判定真孤儿后删除。
func (r *ingestRepository) EnqueueMediaCleanup(ctx context.Context, payload string) error {
now := uint32(time.Now().Unix())
@ -372,32 +371,20 @@ func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error {
// ImageExistsBySha1 在给定图片表中是否已存在相同 content_sha1(精确重复)。
func (r *ingestRepository) ImageExistsBySha1(ctx context.Context, tables []string, sha1 string) (bool, error) {
for _, t := range tables {
var c int64
if err := r.db.WithContext(ctx).Table(t).
Where("content_sha1 = ? AND is_deleted = 0", sha1).Count(&c).Error; err != nil {
return false, err
}
if c > 0 {
return true, nil
}
}
return false, nil
}
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离(pgvector <~> 算子)检索近重复,
// 取距离 ≤ threshold 的最近一条。phashBits 为 vector(64) 二进制向量串;NULL 的 phash 不参与比较。
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,取距离 ≤ threshold 的最近一条。
// 算子用 pgvector 的 L2(<->);因 phash 是 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(threshold)。
// phashBits 为 vector(64) 二进制向量串;NULL 的 phash 不参与比较。
func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) {
// 汉明阈值转 L2 阈值:phash 为 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(汉明阈值)。
l2Limit := math.Sqrt(float64(threshold))
for _, t := range tables {
var row struct {
ID uint32 `gorm:"column:id"`
Dist int `gorm:"column:dist"`
ID uint32 `gorm:"column:id"`
Dist float64 `gorm:"column:dist"`
}
err := r.db.WithContext(ctx).Table(t).
Select("id, (phash <~> ?::vector) AS dist", phashBits).
Where("(phash <~> ?::vector) <= ?", phashBits, threshold).
Select("id, (phash <-> ?::vector) AS dist", phashBits).
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
Order("dist ASC").
Limit(1).
Scan(&row).Error
@ -412,13 +399,13 @@ func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []
}
// isDuplicateKey 兜底:gorm 的 ErrDuplicatedKey 在不同驱动下的封装不一定一致,
// 直接命中 MySQL 1062 错误号更稳。
// 命中驱动返回的唯一约束冲突错误信息更稳(兼容 MySQL/PostgreSQL/SQLite 不同措辞)。
func isDuplicateKey(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return containsAny(msg, "Duplicate entry", "1062", "UNIQUE constraint failed")
return containsAny(msg, "Duplicate entry", "1062", "duplicate key", "violates unique constraint", "UNIQUE constraint failed")
}
func containsAny(s string, subs ...string) bool {

View File

@ -2,10 +2,10 @@ package repository
import "testing"
// 仅覆盖纯函数助手(DB 相关方法需 MySQL,本环境不跑)。
// 仅覆盖纯函数助手(DB 相关方法需连库,本环境不跑)。
func TestContainsAny(t *testing.T) {
if !containsAny("Error 1062: Duplicate entry", "1062") {
t.Fatal("should detect mysql 1062")
t.Fatal("should detect duplicate key (1062)")
}
if !containsAny("UNIQUE constraint failed", "UNIQUE constraint failed") {
t.Fatal("should detect sqlite unique")

View File

@ -6,14 +6,14 @@ import (
"gorm.io/gorm"
)
// MediaRepository 跨表图片引用计数(清理七牛孤儿文件的判定依据)。
// MediaRepository 跨表图片引用计数(清理S4孤儿文件的判定依据)。
//
// 七牛 key 为 sha1 内容寻址:同一张图若出现在多个图集/草稿里,在库中是同一个 key。
// 因此删除图集时绝不能「删库即删七牛」——必须先确认该 key 在全库已无任何引用,
// S4 key 为 sha1 内容寻址:同一张图若出现在多个图集/草稿里,在库中是同一个 key。
// 因此删除图集时绝不能「删库即删S4」——必须先确认该 key 在全库已无任何引用,
// 否则会误删被其他图集仍在用的图片。本接口提供唯一权威的引用计数查询。
type MediaRepository interface {
// CountImageKeyRefs 统计某七牛 key 在全部图片明细表中仍被引用的数量(is_deleted=0)。
// 返回 0 表示该 key 已成孤儿,可安全删除七牛对象。
// CountImageKeyRefs 统计某S4 key 在全部图片明细表中仍被引用的数量(is_deleted=0)。
// 返回 0 表示该 key 已成孤儿,可安全删除S4对象。
CountImageKeyRefs(ctx context.Context, key string) (int, error)
}
@ -26,7 +26,7 @@ func NewMediaRepository(db *gorm.DB) MediaRepository {
return &mediaRepository{db: db}
}
// imageTables 保存七牛 key 的全部图片明细表(已晋升的正式表 + 待审草稿表)。
// imageTables 保存S4 key 的全部图片明细表(已晋升的正式表 + 待审草稿表)。
// 引用计数必须覆盖这 4 张,漏掉任意一张都会把「仍被引用」误判为孤儿而误删。
var imageTables = []string{
"brand_runway_images",

View File

@ -27,9 +27,9 @@ type ReviewRepository interface {
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error
// SetDraftStatus 设置草稿状态(通过/驳回)与审核人/驳回理由。
SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
// SaveRunwayFromDraft 晋升:按实体键(brand_id + season_code + collection_type)幂等 upsert 到 brand_runway 正式表并替换图片,返回正式表主键。
// SaveRunwayFromDraft 晋升:按实体键(brand_id + season_code + collection_type)幂等 upsert 到 brand_runways 正式表并替换图片,返回正式表主键。
SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error)
// 街拍草稿镜像方法(与 runway 同构,但对象为 street_snap_draft / street_snap)。
// 街拍草稿镜像方法(与 runway 同构,但对象为 street_snap_drafts / street_snaps)。
ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error)
StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error)
StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error)
@ -37,7 +37,7 @@ type ReviewRepository interface {
SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。
SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error
// GetDraftImageKey 取单张草稿图片的七牛 key(删除前清理用);不存在返回空串。
// GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。
GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error)
UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error
SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
@ -163,7 +163,7 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
return 0, err
}
// 多来源聚合:当前草稿图 + 同实体已 approved 的其他草稿图(按 image URL 去重,
// 七牛 key 已是 sha1,同图 URL 必相同)。当前正被审核的草稿强制纳入,其余只取已审核来源。
// S4 key 已是 sha1,同图 URL 必相同)。当前正被审核的草稿强制纳入,其余只取已审核来源。
curImgs, err := r.DraftImages(ctx, draftID)
if err != nil {
return 0, err
@ -252,7 +252,6 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
SortOrder: uint32(i + 1),
LookIndex: im.LookIndex,
IsDetail: im.IsDetail,
ContentSha1: im.ContentSha1,
Phash: im.Phash,
IsDuplicate: im.IsDuplicate,
DupOf: im.DupOf,
@ -371,7 +370,7 @@ func (r *reviewRepository) SetStreetDraftStatus(ctx context.Context, id uint32,
}).Error
}
// SaveStreetSnapFromDraft 晋升街拍草稿到正式表 street_snap(按实体键 city+year 幂等 upsert,替换图片)。
// SaveStreetSnapFromDraft 晋升街拍草稿到正式表 street_snaps(按实体键 city+year 幂等 upsert,替换图片)。
// 逻辑与 SaveRunwayFromDraft 同构,但无 brand_id、有 city,图片明细落到 street_snap_images。
// 在事务内完成,保证草稿与正式表一致性。
func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error) {
@ -445,7 +444,6 @@ func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID
Image: im.Image,
Name: im.Name,
SortOrder: uint32(i + 1),
ContentSha1: im.ContentSha1,
Phash: im.Phash,
IsDuplicate: im.IsDuplicate,
DupOf: im.DupOf,
@ -485,7 +483,7 @@ func (r *reviewRepository) runwayApprovedSiblingImages(ctx context.Context, excl
return out, nil
}
// unionRunwayDraftImages 按 image URL 去重合并(七牛 key 已是 sha1,同图 URL 必相同)。当前草稿图优先。
// unionRunwayDraftImages 按 image URL 去重合并(S4 key 已是 sha1,同图 URL 必相同)。当前草稿图优先。
func unionRunwayDraftImages(a, b []model.BrandRunwayDraftImage) []model.BrandRunwayDraftImage {
seen := make(map[string]struct{}, len(a)+len(b))
out := make([]model.BrandRunwayDraftImage, 0, len(a)+len(b))
@ -549,7 +547,7 @@ func unionStreetDraftImages(a, b []model.StreetSnapDraftImage) []model.StreetSna
// ── 草稿图片删除 ────────────────────────────────────────────────────────────────
// SoftDeleteRunwayDraftImage 软删单张走秀草稿图片(审核阶段剔除坏图),并同步草稿 image_count。
// 不删七牛文件(与正式表 DeleteRunwayImage 行为一致:仅 DB 标记,避免误删被引用文件)。
// 不删S4文件(与正式表 DeleteRunwayImage 行为一致:仅 DB 标记,避免误删被引用文件)。
func (r *reviewRepository) SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error {
if err := r.db.WithContext(ctx).
Model(&model.BrandRunwayDraftImage{}).
@ -558,7 +556,7 @@ func (r *reviewRepository) SoftDeleteRunwayDraftImage(ctx context.Context, draft
return err
}
return r.db.WithContext(ctx).
Exec("UPDATE brand_runway_draft SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error
Exec("UPDATE brand_runway_drafts SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error
}
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。
@ -570,10 +568,10 @@ func (r *reviewRepository) SoftDeleteStreetDraftImage(ctx context.Context, draft
return err
}
return r.db.WithContext(ctx).
Exec("UPDATE street_snap_draft SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error
Exec("UPDATE street_snap_drafts SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error
}
// GetDraftImageKey 取单张草稿图片的七牛 key(删除前清理用);不存在返回空串。
// GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。
// kind 决定查哪张草稿图片表(runway / street);未知 kind 返回 ErrNotFound。
func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error) {
switch kind {

View File

@ -30,7 +30,7 @@ type StreetSnapRepository interface {
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
// DeleteSnapImage 软删单张街拍图片。
DeleteSnapImage(ctx context.Context, imageID uint32) error
// GetSnapImageKey 取单张街拍图片的七牛 key(删除前清理用);不存在返回空串。
// GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。
GetSnapImageKey(ctx context.Context, imageID uint32) (string, error)
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。
SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error)
@ -67,11 +67,11 @@ func snapFilterScope(q dto.StreetSnapQuery) func(*gorm.DB) *gorm.DB {
func snapOrderBy(sort string) string {
switch sort {
case "year_asc":
return "street_snap.year ASC, street_snap.id ASC"
return "street_snaps.year ASC, street_snaps.id ASC"
case "year_desc":
return "street_snap.year DESC, street_snap.id DESC"
return "street_snaps.year DESC, street_snaps.id DESC"
default: // image_count
return "street_snap.image_count DESC, street_snap.id DESC"
return "street_snaps.image_count DESC, street_snaps.id DESC"
}
}
@ -222,7 +222,7 @@ func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint
}
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用)。
// 先取非删除图片的 key 列表返回给调用方(用于后续七牛孤儿清理),再统一置 is_deleted=1。
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error) {
var imgs []model.StreetSnapImage
if err := r.db.WithContext(ctx).