This commit is contained in:
toom1996
2026-09-07 00:04:01 +08:00
parent 6a5a4378ab
commit 10d8a96e8c
29 changed files with 5349 additions and 0 deletions

View File

@ -0,0 +1,622 @@
package repository
import (
"context"
"errors"
"time"
"fashionapi/internal/model"
"gorm.io/gorm"
)
// IngestRepository 爬虫入库管线专属仓储:任务队列(ingest_jobs)+ nonce 防重放
// (ingest_nces)+ 走秀正式表写入(brand_runway / brand_runway_images,按 source_url 去重)。
//
// 入队与领取用同一张 ingest_jobs 表,领取靠 MySQL 的 FOR UPDATE SKIP LOCKED
// 实现「多 worker 安全并发」——同一条任务只会被一个 worker 拿到,其它 worker 跳过它。
type IngestRepository interface {
// Enqueue 写入一条待处理任务(payload 为原始 JSON)。
Enqueue(ctx context.Context, job *model.IngestJob) error
// Claim 原子领取最多 limit 条 pending 任务并置为 processing,返回这些任务。
// 用 SKIP LOCKED 保证多 worker 不抢同一条。
Claim(ctx context.Context, limit int) ([]model.IngestJob, error)
// MarkDone 标记任务成功。
MarkDone(ctx context.Context, id uint32) error
// MarkFailed 标记任务失败并记录错误(attempts 自增)。
MarkFailed(ctx context.Context, id uint32, errMsg string) error
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
ReserveNonce(ctx context.Context, nonce string) (ok bool, err error)
// RunwayIDBySourceURL 按来源链接查是否已存在走秀;返回 (id, found)。
RunwayIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error)
// CreateRunway 插入走秀正式行,返回自增主键。
CreateRunway(ctx context.Context, r *model.BrandRunway) (uint32, error)
// CreateRunwayImages 批量插入走秀图片行。
CreateRunwayImages(ctx context.Context, imgs []model.BrandRunwayImage) error
// CreateRunwayDraft 插入走秀草稿行(status=pending),返回自增主键。
CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error)
// CreateRunwayDraftImages 批量插入草稿图片行。
CreateRunwayDraftImages(ctx context.Context, imgs []model.BrandRunwayDraftImage) error
// DraftIDBySourceURL 按来源链接查是否已有 pending 草稿;返回 (id, found)。
DraftIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error)
// StreetSnapIDBySourceURL 按来源链接查是否已存在街拍正式表;返回 (id, found)。
StreetSnapIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error)
// DraftStreetIDBySourceURL 按来源链接查是否已有 pending 街拍草稿;返回 (id, found)。
DraftStreetIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error)
// RunwayIDByUnique 按实体键(品牌+季节码+系列)查是否已存在正式走秀;返回 (id, found)。
// 多来源爬同品牌同季时应合并为一条,故去重键是实体而非 source_url。
RunwayIDByUnique(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error)
// DraftRunwayIDByUnique 按实体键查是否已有 pending 走秀草稿;返回 (id, found)。
DraftRunwayIDByUnique(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error)
// StreetSnapIDByUnique 按实体键(城市+年份)查是否已存在街拍正式表;返回 (id, found)。
StreetSnapIDByUnique(ctx context.Context, city string, year uint16) (uint32, bool, error)
// DraftStreetSnapIDByUnique 按实体键查是否已有 pending 街拍草稿;返回 (id, found)。
DraftStreetSnapIDByUnique(ctx context.Context, city string, year uint16) (uint32, bool, error)
// CreateStreetSnapDraft 插入街拍草稿行(status=pending),返回自增主键。
CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error)
// CreateStreetSnapDraftImages 批量插入街拍草稿图片行。
CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error
// ListJobs 按 id 倒序列出最近的入库任务(用于后台监控页)。
ListJobs(ctx context.Context, limit int) ([]model.IngestJob, error)
// RetryJob 把一条 failed 任务重置回 pending,清 last_error/locked_at,等待 worker 重新处理。
RetryJob(ctx context.Context, id uint32) error
// EnqueueMediaCleanup 写入一条「清理七牛孤儿图」任务(payload 为待清理 key 的 JSON)。
// 由删除图集的服务调用,把同步的七牛删除改为异步队列,避免阻塞删除请求。
EnqueueMediaCleanup(ctx context.Context, payload string) error
// ScheduleRetry 失败时调用:attempts+1,未达上限则退避后重置 pending,达上限则置 failed。
// 用于临时失败(网络抖动 / 单图下载失败)的自动重试,区别于永久失败(payload 解析错等)直接 MarkFailed。
ScheduleRetry(ctx context.Context, id uint32, errMsg string) error
// ExistsSourceURLs 批量判断一批 source_url 是否已爬取过,返回命中集合(true = 已存在,无需再抓)。
//
// 语义必须与 worker 判重(processRunway / processStreet)严格一致,否则会出现
// 「预检说没有、worker 又判重命中」的重复抓取,或「预检说有、实际已被拒/已删」的漏抓:
// - 正式表(brand_runway / street_snap):source_url 命中且 is_deleted = 0
// - 草稿表(brand_runway_draft / street_snap_draft):status = pending 且 is_deleted = 0
// —— 被审核拒绝(rejected)的草稿不算已存在,允许重新抓取。
ExistsSourceURLs(ctx context.Context, urls []string) (map[string]bool, error)
}
type ingestRepository struct {
db *gorm.DB
}
// NewIngestRepository 创建入库管线仓储。
func NewIngestRepository(db *gorm.DB) IngestRepository {
return &ingestRepository{db: db}
}
func (r *ingestRepository) Enqueue(ctx context.Context, job *model.IngestJob) error {
now := uint32(time.Now().Unix())
job.CreatedAt = now
job.UpdatedAt = now
job.Status = model.IngestStatusPending
return r.db.WithContext(ctx).Create(job).Error
}
// Claim 在事务内 SELECT ... FOR UPDATE SKIP LOCKED 锁定 pending 行,
// 立即置为 processing,再返回这些行,保证领取与状态变更原子、且不被其它 worker 重复领取。
//
// 事务内先做两件事:
// 1. 回收卡死的 processing 任务——worker 崩溃/被杀会留下 processing 孤儿永久卡死,
// 锁定超时(IngestStuckTimeoutSec)后重置回 pending 并立即可领(next_attempt_at=now)。
// 2. 仅领取「已到重试时间」的 pending(next_attempt_at <= now),未到退避点的暂不领。
func (r *ingestRepository) Claim(ctx context.Context, limit int) ([]model.IngestJob, error) {
if limit < 1 {
limit = 10
}
now := uint32(time.Now().Unix())
tx := r.db.WithContext(ctx).Begin()
if tx.Error != nil {
return nil, tx.Error
}
defer func() {
if tx.Error != nil {
tx.Rollback()
}
}()
// 1) 回收卡死的 processing 任务(仅当时间足够大,避免服务器启动初期把刚领取的任务误回收)。
if now > model.IngestStuckTimeoutSec {
if err := tx.Model(&model.IngestJob{}).
Where("status = ? AND locked_at > 0 AND locked_at < ?",
model.IngestStatusProcessing, now-model.IngestStuckTimeoutSec).
Updates(map[string]any{
"status": model.IngestStatusPending,
"locked_at": 0,
"next_attempt_at": now,
"updated_at": now,
}).Error; err != nil {
return nil, err
}
}
// 2) 领取 pending 且已到重试时间的任务(含刚回收的 + 新入队的 + 退避已到期的)。
// 优先处理 media_cleanup(删除图集时异步清理七牛),避免被大批 crawl 任务排到后面、清理迟迟不触发。
var ids []uint32
if err := tx.Raw(
"SELECT id FROM ingest_jobs WHERE status = ? AND next_attempt_at <= ? "+
"ORDER BY CASE kind WHEN ? THEN 0 ELSE 1 END, id ASC LIMIT ? FOR UPDATE SKIP LOCKED",
model.IngestStatusPending, now, model.IngestKindMediaCleanup, limit,
).Scan(&ids).Error; err != nil {
return nil, err
}
if len(ids) == 0 {
tx.Commit()
return nil, nil
}
if err := tx.Model(&model.IngestJob{}).
Where("id IN ?", ids).
Updates(map[string]any{
"status": model.IngestStatusProcessing,
"locked_at": now,
"updated_at": now,
}).Error; err != nil {
return nil, err
}
if err := tx.Commit().Error; err != nil {
return nil, err
}
var jobs []model.IngestJob
if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&jobs).Error; err != nil {
return nil, err
}
return jobs, nil
}
func (r *ingestRepository) MarkDone(ctx context.Context, id uint32) error {
return r.db.WithContext(ctx).
Model(&model.IngestJob{}).
Where("id = ?", id).
Updates(map[string]any{
"status": model.IngestStatusDone,
"last_error": "",
"updated_at": uint32(time.Now().Unix()),
}).Error
}
func (r *ingestRepository) MarkFailed(ctx context.Context, id uint32, errMsg string) error {
return r.db.WithContext(ctx).
Model(&model.IngestJob{}).
Where("id = ?", id).
Updates(map[string]any{
"status": model.IngestStatusFailed,
"attempts": gorm.Expr("attempts + 1"),
"last_error": errMsg,
"updated_at": uint32(time.Now().Unix()),
}).Error
}
// ScheduleRetry 失败时调度自动重试:attempts+1,未达上限(IngestMaxAttempts)则按指数退避
// 重置为 pending 并写入 next_attempt_at(到点才可被 Claim 领取);达上限则置 failed,需人工处理。
// 用于临时失败(网络抖动 / 单图下载失败),区别于永久失败(payload 解析错等)直接 MarkFailed。
func (r *ingestRepository) ScheduleRetry(ctx context.Context, id uint32, errMsg string) error {
now := uint32(time.Now().Unix())
var job model.IngestJob
if err := r.db.WithContext(ctx).Select("attempts").Where("id = ?", id).First(&job).Error; err != nil {
return err
}
attempts := int(job.Attempts) + 1
if attempts >= model.IngestMaxAttempts {
return r.db.WithContext(ctx).
Model(&model.IngestJob{}).
Where("id = ?", id).
Updates(map[string]any{
"status": model.IngestStatusFailed,
"attempts": attempts,
"last_error": errMsg,
"updated_at": now,
}).Error
}
delay := model.IngestRetryBackoff(attempts)
return r.db.WithContext(ctx).
Model(&model.IngestJob{}).
Where("id = ?", id).
Updates(map[string]any{
"status": model.IngestStatusPending,
"attempts": attempts,
"last_error": errMsg,
"next_attempt_at": now + uint32(delay),
"locked_at": 0,
"updated_at": now,
}).Error
}
// EnqueueMediaCleanup 写入一条「清理七牛孤儿图」任务(kind=media_cleanup),
// payload 为待清理 key 的 JSON,由 worker 的 processMediaCleanup 按引用计数判定真孤儿后删除。
func (r *ingestRepository) EnqueueMediaCleanup(ctx context.Context, payload string) error {
now := uint32(time.Now().Unix())
job := &model.IngestJob{
Kind: model.IngestKindMediaCleanup,
Payload: payload,
CreatedAt: now,
UpdatedAt: now,
Status: model.IngestStatusPending,
}
return r.db.WithContext(ctx).Create(job).Error
}
// ReserveNonce 写入一次性随机串;依赖 ingest_nces.nonce 主键唯一约束,
// 重复插入触发 DuplicateEntry → 视为重放,返回 ok=false。
func (r *ingestRepository) ReserveNonce(ctx context.Context, nonce string) (bool, error) {
err := r.db.WithContext(ctx).Create(&model.IngestNonce{
Nonce: nonce,
CreatedAt: uint32(time.Now().Unix()),
}).Error
if err != nil {
// 唯一键冲突 → 重放
if errors.Is(err, gorm.ErrDuplicatedKey) || isDuplicateKey(err) {
return false, nil
}
return false, err
}
return true, nil
}
func (r *ingestRepository) RunwayIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) {
if sourceURL == "" {
return 0, false, nil
}
var row struct {
ID uint32 `gorm:"column:id"`
}
err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Select("id").
Where("source_url = ? AND is_deleted = 0", sourceURL).
Limit(1).
Scan(&row).Error
if err != nil {
return 0, false, err
}
if row.ID == 0 {
return 0, false, nil
}
return row.ID, true, nil
}
func (r *ingestRepository) CreateRunway(ctx context.Context, rw *model.BrandRunway) (uint32, error) {
now := uint32(time.Now().Unix())
rw.CreatedAt = now
rw.UpdatedAt = now
if rw.IsDeleted == 0 {
rw.IsDeleted = 0
}
if err := r.db.WithContext(ctx).Create(rw).Error; err != nil {
return 0, err
}
return rw.ID, nil
}
func (r *ingestRepository) CreateRunwayImages(ctx context.Context, imgs []model.BrandRunwayImage) error {
if len(imgs) == 0 {
return nil
}
return r.db.WithContext(ctx).Create(&imgs).Error
}
func (r *ingestRepository) CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error) {
now := uint32(time.Now().Unix())
d.CreatedAt = now
d.UpdatedAt = now
if d.Status == "" {
d.Status = model.DraftStatusPending
}
if err := r.db.WithContext(ctx).Create(d).Error; err != nil {
return 0, err
}
return d.ID, nil
}
func (r *ingestRepository) CreateRunwayDraftImages(ctx context.Context, imgs []model.BrandRunwayDraftImage) error {
if len(imgs) == 0 {
return nil
}
now := uint32(time.Now().Unix())
for i := range imgs {
imgs[i].CreatedAt = now
imgs[i].UpdatedAt = now
}
return r.db.WithContext(ctx).Create(&imgs).Error
}
func (r *ingestRepository) DraftIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) {
if sourceURL == "" {
return 0, false, nil
}
var row struct {
ID uint32 `gorm:"column:id"`
}
err := r.db.WithContext(ctx).
Model(&model.BrandRunwayDraft{}).
Select("id").
Where("source_url = ? AND status = ? AND is_deleted = 0", sourceURL, model.DraftStatusPending).
Limit(1).
Scan(&row).Error
if err != nil {
return 0, false, err
}
if row.ID == 0 {
return 0, false, nil
}
return row.ID, true, nil
}
func (r *ingestRepository) StreetSnapIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) {
if sourceURL == "" {
return 0, false, nil
}
var row struct {
ID uint32 `gorm:"column:id"`
}
err := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Select("id").
Where("source_url = ? AND is_deleted = 0", sourceURL).
Limit(1).
Scan(&row).Error
if err != nil {
return 0, false, err
}
if row.ID == 0 {
return 0, false, nil
}
return row.ID, true, nil
}
func (r *ingestRepository) DraftStreetIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) {
if sourceURL == "" {
return 0, false, nil
}
var row struct {
ID uint32 `gorm:"column:id"`
}
err := r.db.WithContext(ctx).
Model(&model.StreetSnapDraft{}).
Select("id").
Where("source_url = ? AND status = ? AND is_deleted = 0", sourceURL, model.DraftStatusPending).
Limit(1).
Scan(&row).Error
if err != nil {
return 0, false, err
}
if row.ID == 0 {
return 0, false, nil
}
return row.ID, true, nil
}
// RunwayIDByUnique 按实体键(品牌+季节码+系列)查正式走秀,多来源同实体合并为一条。
func (r *ingestRepository) RunwayIDByUnique(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) {
var row struct {
ID uint32 `gorm:"column:id"`
}
err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Select("id").
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", brandID, seasonCode, collectionType).
Limit(1).
Scan(&row).Error
if err != nil {
return 0, false, err
}
if row.ID == 0 {
return 0, false, nil
}
return row.ID, true, nil
}
// DraftRunwayIDByUnique 按实体键查 pending 走秀草稿。
func (r *ingestRepository) DraftRunwayIDByUnique(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) {
var row struct {
ID uint32 `gorm:"column:id"`
}
err := r.db.WithContext(ctx).
Model(&model.BrandRunwayDraft{}).
Select("id").
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND status = ? AND is_deleted = 0", brandID, seasonCode, collectionType, model.DraftStatusPending).
Limit(1).
Scan(&row).Error
if err != nil {
return 0, false, err
}
if row.ID == 0 {
return 0, false, nil
}
return row.ID, true, nil
}
// StreetSnapIDByUnique 按实体键(城市+年份)查街拍正式表;城市为空则不去重(避免空城市互并)。
func (r *ingestRepository) StreetSnapIDByUnique(ctx context.Context, city string, year uint16) (uint32, bool, error) {
if city == "" {
return 0, false, nil
}
var row struct {
ID uint32 `gorm:"column:id"`
}
err := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Select("id").
Where("city = ? AND year = ? AND is_deleted = 0", city, year).
Limit(1).
Scan(&row).Error
if err != nil {
return 0, false, err
}
if row.ID == 0 {
return 0, false, nil
}
return row.ID, true, nil
}
// DraftStreetSnapIDByUnique 按实体键查 pending 街拍草稿;城市为空则不去重。
func (r *ingestRepository) DraftStreetSnapIDByUnique(ctx context.Context, city string, year uint16) (uint32, bool, error) {
if city == "" {
return 0, false, nil
}
var row struct {
ID uint32 `gorm:"column:id"`
}
err := r.db.WithContext(ctx).
Model(&model.StreetSnapDraft{}).
Select("id").
Where("city = ? AND year = ? AND status = ? AND is_deleted = 0", city, year, model.DraftStatusPending).
Limit(1).
Scan(&row).Error
if err != nil {
return 0, false, err
}
if row.ID == 0 {
return 0, false, nil
}
return row.ID, true, nil
}
func (r *ingestRepository) CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error) {
now := uint32(time.Now().Unix())
d.CreatedAt = now
d.UpdatedAt = now
if d.Status == "" {
d.Status = model.DraftStatusPending
}
if err := r.db.WithContext(ctx).Create(d).Error; err != nil {
return 0, err
}
return d.ID, nil
}
func (r *ingestRepository) CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error {
if len(imgs) == 0 {
return nil
}
now := uint32(time.Now().Unix())
for i := range imgs {
imgs[i].CreatedAt = now
imgs[i].UpdatedAt = now
}
return r.db.WithContext(ctx).Create(&imgs).Error
}
// ListJobs 按 id 倒序列出最近的入库任务(后台监控页用)。
func (r *ingestRepository) ListJobs(ctx context.Context, limit int) ([]model.IngestJob, error) {
if limit < 1 {
limit = 200
}
var jobs []model.IngestJob
if err := r.db.WithContext(ctx).Order("id DESC").Limit(limit).Find(&jobs).Error; err != nil {
return nil, err
}
return jobs, nil
}
// RetryJob 把一条 failed 任务重置回 pending,清掉 last_error / locked_at / next_attempt_at,
// 让 worker(每 3s 扫一次 pending)立即重新拉起处理。仅对 failed 生效,其它状态原样不动。
func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error {
now := uint32(time.Now().Unix())
return r.db.WithContext(ctx).
Model(&model.IngestJob{}).
Where("id = ? AND status = ?", id, model.IngestStatusFailed).
Updates(map[string]any{
"status": model.IngestStatusPending,
"locked_at": 0,
"last_error": "",
"next_attempt_at": 0,
"updated_at": now,
}).Error
}
// isDuplicateKey 兜底:gorm 的 ErrDuplicatedKey 在不同驱动下的封装不一定一致,
// 直接命中 MySQL 1062 错误号更稳。
func isDuplicateKey(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return containsAny(msg, "Duplicate entry", "1062", "UNIQUE constraint failed")
}
func containsAny(s string, subs ...string) bool {
for _, sub := range subs {
if len(sub) > 0 && indexOf(s, sub) >= 0 {
return true
}
}
return false
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// ExistsSourceURLs 批量判断 source_url 是否已爬取过(预检接口用)。
//
// 走秀与街拍的正式表、草稿表各查一次 IN,命中即标记。与 worker 判重共用同一套条件,
// 保证「爬虫预检跳过」与「worker 判重跳过」判定结果一致。
func (r *ingestRepository) ExistsSourceURLs(ctx context.Context, urls []string) (map[string]bool, error) {
out := make(map[string]bool)
if len(urls) == 0 {
return out, nil
}
// 去重 + 去空:同一批 URL 可能重复出现,避免无谓的返回行与 SQL 长度。
uniq := make([]string, 0, len(urls))
seen := make(map[string]struct{}, len(urls))
for _, u := range urls {
if u == "" {
continue
}
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
uniq = append(uniq, u)
}
if len(uniq) == 0 {
return out, nil
}
// 单次请求上限,避免超长 IN 拖慢数据库(超出部分按「未抓过」处理,最多多抓几个,不会漏判已存在的)。
const maxBatch = 500
if len(uniq) > maxBatch {
uniq = uniq[:maxBatch]
}
// 四张表:正式表只看未删除,草稿表只认 pending(被拒草稿允许重抓)。
queries := []struct {
dest any
where string
args []any
}{
{dest: &model.BrandRunway{}, where: "source_url IN ? AND is_deleted = 0"},
{dest: &model.StreetSnap{}, where: "source_url IN ? AND is_deleted = 0"},
{dest: &model.BrandRunwayDraft{}, where: "source_url IN ? AND status = ? AND is_deleted = 0", args: []any{model.DraftStatusPending}},
{dest: &model.StreetSnapDraft{}, where: "source_url IN ? AND status = ? AND is_deleted = 0", args: []any{model.DraftStatusPending}},
}
type hit struct {
SourceURL string `gorm:"column:source_url"`
}
for i := range queries {
var rows []hit
q := r.db.WithContext(ctx).
Model(queries[i].dest).
Select("source_url").
Where(queries[i].where, append([]any{uniq}, queries[i].args...)...)
if err := q.Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
out[row.SourceURL] = true
}
// 全部命中就无需再查后面的表。
if len(out) == len(uniq) {
break
}
}
return out, nil
}

View File

@ -0,0 +1,25 @@
package repository
import "testing"
// 仅覆盖纯函数助手(DB 相关方法需 MySQL,本环境不跑)。
func TestContainsAny(t *testing.T) {
if !containsAny("Error 1062: Duplicate entry", "1062") {
t.Fatal("should detect mysql 1062")
}
if !containsAny("UNIQUE constraint failed", "UNIQUE constraint failed") {
t.Fatal("should detect sqlite unique")
}
if containsAny("something else", "1062") {
t.Fatal("should not false-positive")
}
}
func TestIndexOf(t *testing.T) {
if indexOf("abcde", "cd") != 2 {
t.Fatal("indexOf mismatch")
}
if indexOf("abc", "z") != -1 {
t.Fatal("indexOf should return -1")
}
}

View File

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

View File

@ -0,0 +1,607 @@
package repository
import (
"context"
"errors"
"time"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"gorm.io/gorm"
)
// ReviewRepository 审核草稿的数据访问接口。
//
// 草稿由爬虫 worker 写入(见 IngestRepository),本接口负责「读 + 晋升」:
// 列出待审草稿、查看详情、审核前微调字段、通过时晋升到正式表、驳回时标记状态。
type ReviewRepository interface {
// ListDrafts 分页列出草稿;status 为空表示全部,否则按状态过滤。
// 排序:pending 优先,其次按 id 倒序(最新在前)。
ListDrafts(ctx context.Context, status string, page, size int) ([]model.BrandRunwayDraft, int64, error)
// DraftDetail 查单条草稿(不含图片)。
DraftDetail(ctx context.Context, id uint32) (*model.BrandRunwayDraft, error)
// DraftImages 查草稿的全部图片,按排序值升序。
DraftImages(ctx context.Context, draftID uint32) ([]model.BrandRunwayDraftImage, error)
// UpdateDraft 审核前微调草稿字段(白名单,杜绝任意列注入)。
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error
// SetDraftStatus 设置草稿状态(通过/驳回)与审核人/驳回理由。
SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
// SaveRunwayFromDraft 晋升:按 source_url 幂等 upsert 到 brand_runway 正式表并替换图片,返回正式表主键。
SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error)
// 街拍草稿镜像方法(与 runway 同构,但对象为 street_snap_draft / street_snap)。
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)
// SoftDeleteRunwayDraftImage 软删单张走秀草稿图片(审核阶段剔除坏图),并同步 image_count。
SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。
SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error
// GetDraftImageKey 取单张草稿图片的七牛 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
SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error)
// CountPending 统计 pending 草稿数(合并 runway + street,仪表盘卡片用)。
CountPending(ctx context.Context) (int64, error)
// CountDraftPending 统计某草稿表(dest 传表对应的零值指针,如 &model.BrandRunwayDraft{})中指定状态的草稿数。
// 供各 ReviewModule 复用,新增模块无需再单独写计数方法。
CountDraftPending(ctx context.Context, status string, dest any) (int64, error)
}
type reviewRepository struct {
db *gorm.DB
}
// NewReviewRepository 创建审核仓储。
func NewReviewRepository(db *gorm.DB) ReviewRepository {
return &reviewRepository{db: db}
}
func (r *reviewRepository) ListDrafts(ctx context.Context, status string, page, size int) ([]model.BrandRunwayDraft, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
q := r.db.WithContext(ctx).Model(&model.BrandRunwayDraft{}).Where("is_deleted = 0")
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []model.BrandRunwayDraft{}, 0, nil
}
var rows []model.BrandRunwayDraft
if err := q.
Order("CASE status WHEN '" + model.DraftStatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
Offset((page - 1) * size).
Limit(size).
Find(&rows).Error; err != nil {
return nil, 0, err
}
return rows, total, nil
}
func (r *reviewRepository) DraftDetail(ctx context.Context, id uint32) (*model.BrandRunwayDraft, error) {
var d model.BrandRunwayDraft
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return &d, nil
}
func (r *reviewRepository) DraftImages(ctx context.Context, draftID uint32) ([]model.BrandRunwayDraftImage, error) {
var imgs []model.BrandRunwayDraftImage
err := r.db.WithContext(ctx).
Where("draft_id = ? AND is_deleted = 0", draftID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error
return imgs, err
}
// draftEditable 审核前可微调的字段白名单(键即列名)。
var draftEditable = map[string]bool{
"title_en": true,
"title_cn": true,
"description_en": true,
"description_cn": true,
"year": true,
"season": true,
"collection_type": true,
"season_code": true,
}
// UpdateDraft 只更新白名单内的字段,其余忽略,杜绝任意列注入。
func (r *reviewRepository) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error {
clean := map[string]any{}
for k, v := range fields {
if draftEditable[k] {
clean[k] = v
}
}
if len(clean) == 0 {
return nil
}
clean["updated_at"] = uint32(time.Now().Unix())
return r.db.WithContext(ctx).
Model(&model.BrandRunwayDraft{}).
Where("id = ?", id).
Updates(clean).Error
}
func (r *reviewRepository) SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
return r.db.WithContext(ctx).
Model(&model.BrandRunwayDraft{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"reviewer": reviewer,
"reject_reason": reason,
"updated_at": uint32(time.Now().Unix()),
}).Error
}
// SaveRunwayFromDraft 晋升草稿到正式表:
// 1. 按 source_url 查找已存在的正式走秀(幂等:重审同一草稿只更新,不重复建行);
// 2. 存在则更新全部可编辑字段 + 图片数;不存在则新建;
// 3. 替换图片:软删旧图(is_deleted=1)+ 按草稿图片重建;
// 4. 返回正式表主键。
//
// 在事务内完成,保证草稿与正式表一致性。
func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error) {
var draft model.BrandRunwayDraft
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", draftID).First(&draft).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, ErrNotFound
}
return 0, err
}
// 多来源聚合:当前草稿图 + 同实体已 approved 的其他草稿图(按 image URL 去重,
// 七牛 key 已是 sha1,同图 URL 必相同)。当前正被审核的草稿强制纳入,其余只取已审核来源。
curImgs, err := r.DraftImages(ctx, draftID)
if err != nil {
return 0, err
}
sibImgs, err := r.runwayApprovedSiblingImages(ctx, draftID, draft.BrandID, draft.SeasonCode, draft.CollectionType)
if err != nil {
return 0, err
}
imgs := unionRunwayDraftImages(curImgs, sibImgs)
now := uint32(time.Now().Unix())
var runwayID uint32
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1) 按 source_url 查找既有正式走秀
var existing model.BrandRunway
if eErr := tx.Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", draft.BrandID, draft.SeasonCode, draft.CollectionType).
Limit(1).Find(&existing).Error; eErr != nil {
return eErr
}
common := map[string]any{
"title_en": draft.TitleEn,
"title_cn": draft.TitleCn,
"description_en": draft.DescriptionEn,
"description_cn": draft.DescriptionCn,
"year": draft.Year,
"season": draft.Season,
"collection_type": draft.CollectionType,
"season_code": draft.SeasonCode,
"cover": draft.Cover,
"source_url": draft.SourceURL,
"image_count": uint16(len(imgs)),
"updated_at": now,
}
if existing.ID != 0 {
runwayID = existing.ID
common["brand_id"] = existing.BrandID // 保留正式表的品牌归属
if uErr := tx.Model(&model.BrandRunway{}).Where("id = ?", runwayID).Updates(common).Error; uErr != nil {
return uErr
}
} else {
rw := &model.BrandRunway{
BrandID: draft.BrandID,
TitleEn: draft.TitleEn,
TitleCn: draft.TitleCn,
DescriptionEn: draft.DescriptionEn,
DescriptionCn: draft.DescriptionCn,
Year: draft.Year,
Season: draft.Season,
CollectionType: draft.CollectionType,
SeasonCode: draft.SeasonCode,
Cover: draft.Cover,
SourceURL: draft.SourceURL,
ImageCount: uint16(len(imgs)),
CreatedAt: now,
UpdatedAt: now,
}
if cErr := tx.Create(rw).Error; cErr != nil {
return cErr
}
runwayID = rw.ID
}
// 2) 替换图片:软删旧 + 重建
if dErr := tx.Model(&model.BrandRunwayImage{}).
Where("runway_id = ? AND is_deleted = 0", runwayID).
Updates(map[string]any{"is_deleted": 1, "updated_at": now}).Error; dErr != nil {
return dErr
}
if len(imgs) > 0 {
rows := make([]model.BrandRunwayImage, 0, len(imgs))
for i, im := range imgs {
rows = append(rows, model.BrandRunwayImage{
RunwayID: runwayID,
BrandID: draft.BrandID,
Image: im.Image,
Name: im.Name,
SortOrder: uint32(i + 1),
CreatedAt: now,
UpdatedAt: now,
})
}
if cErr := tx.Create(&rows).Error; cErr != nil {
return cErr
}
}
return nil
})
if err != nil {
return 0, err
}
return runwayID, nil
}
func (r *reviewRepository) CountPending(ctx context.Context) (int64, error) {
var n1, n2 int64
if err := r.db.WithContext(ctx).
Model(&model.BrandRunwayDraft{}).
Where("status = ? AND is_deleted = 0", model.DraftStatusPending).
Count(&n1).Error; err != nil {
return 0, err
}
if err := r.db.WithContext(ctx).
Model(&model.StreetSnapDraft{}).
Where("status = ? AND is_deleted = 0", model.DraftStatusPending).
Count(&n2).Error; err != nil {
return 0, err
}
return n1 + n2, nil
}
// CountDraftPending 统计传入表(dest 零值指针)中指定状态的草稿数;供各 ReviewModule 复用。
func (r *reviewRepository) CountDraftPending(ctx context.Context, status string, dest any) (int64, error) {
var n int64
if err := r.db.WithContext(ctx).
Model(dest).
Where("status = ? AND is_deleted = 0", status).
Count(&n).Error; err != nil {
return 0, err
}
return n, nil
}
// ---- 街拍草稿镜像实现(与 runway 同构) ----
func (r *reviewRepository) ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
q := r.db.WithContext(ctx).Model(&model.StreetSnapDraft{}).Where("is_deleted = 0")
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []model.StreetSnapDraft{}, 0, nil
}
var rows []model.StreetSnapDraft
if err := q.
Order("CASE status WHEN '" + model.DraftStatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
Offset((page - 1) * size).
Limit(size).
Find(&rows).Error; err != nil {
return nil, 0, err
}
return rows, total, nil
}
func (r *reviewRepository) StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error) {
var d model.StreetSnapDraft
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return &d, nil
}
func (r *reviewRepository) StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error) {
var imgs []model.StreetSnapDraftImage
err := r.db.WithContext(ctx).
Where("draft_id = ? AND is_deleted = 0", draftID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error
return imgs, err
}
// streetDraftEditable 街拍草稿可微调字段白名单(title / year / city)。
var streetDraftEditable = map[string]bool{
"title": true,
"year": true,
"city": true,
}
func (r *reviewRepository) UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error {
clean := map[string]any{}
for k, v := range fields {
if streetDraftEditable[k] {
clean[k] = v
}
}
if len(clean) == 0 {
return nil
}
clean["updated_at"] = uint32(time.Now().Unix())
return r.db.WithContext(ctx).
Model(&model.StreetSnapDraft{}).
Where("id = ?", id).
Updates(clean).Error
}
func (r *reviewRepository) SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
return r.db.WithContext(ctx).
Model(&model.StreetSnapDraft{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"reviewer": reviewer,
"reject_reason": reason,
"updated_at": uint32(time.Now().Unix()),
}).Error
}
// SaveStreetSnapFromDraft 晋升街拍草稿到正式表 street_snap(按 source_url 幂等 upsert,替换图片)。
// 逻辑与 SaveRunwayFromDraft 同构,但无 brand_id、有 city,图片明细落到 street_snap_images。
// 在事务内完成,保证草稿与正式表一致性。
func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error) {
var draft model.StreetSnapDraft
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", draftID).First(&draft).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, ErrNotFound
}
return 0, err
}
curImgs, err := r.StreetDraftImages(ctx, draftID)
if err != nil {
return 0, err
}
sibImgs, err := r.streetApprovedSiblingImages(ctx, draftID, draft.City, draft.Year)
if err != nil {
return 0, err
}
imgs := unionStreetDraftImages(curImgs, sibImgs)
now := uint32(time.Now().Unix())
var snapID uint32
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var existing model.StreetSnap
if eErr := tx.Where("city = ? AND year = ? AND is_deleted = 0", draft.City, draft.Year).
Limit(1).Find(&existing).Error; eErr != nil {
return eErr
}
common := map[string]any{
"title": draft.Title,
"year": draft.Year,
"city": draft.City,
"cover": draft.Cover,
"source_url": draft.SourceURL,
"image_count": uint16(len(imgs)),
"updated_at": now,
}
if existing.ID != 0 {
snapID = existing.ID
if uErr := tx.Model(&model.StreetSnap{}).Where("id = ?", snapID).Updates(common).Error; uErr != nil {
return uErr
}
} else {
snap := &model.StreetSnap{
Title: draft.Title,
Year: draft.Year,
City: draft.City,
Cover: draft.Cover,
SourceURL: draft.SourceURL,
ImageCount: uint16(len(imgs)),
CreatedAt: now,
UpdatedAt: now,
}
if cErr := tx.Create(snap).Error; cErr != nil {
return cErr
}
snapID = snap.ID
}
// 替换图片:软删旧 + 重建
if dErr := tx.Model(&model.StreetSnapImage{}).
Where("snap_id = ? AND is_deleted = 0", snapID).
Updates(map[string]any{"is_deleted": 1, "updated_at": now}).Error; dErr != nil {
return dErr
}
if len(imgs) > 0 {
rows := make([]model.StreetSnapImage, 0, len(imgs))
for i, im := range imgs {
rows = append(rows, model.StreetSnapImage{
SnapID: snapID,
Image: im.Image,
Name: im.Name,
SortOrder: uint32(i + 1),
CreatedAt: now,
UpdatedAt: now,
})
}
if cErr := tx.Create(&rows).Error; cErr != nil {
return cErr
}
}
return nil
})
if err != nil {
return 0, err
}
return snapID, nil
}
// runwayApprovedSiblingImages 返回同实体(品牌+季节码+系列)且已 approved 的其他草稿的图片,
// 用于多来源聚合:当前正被审核的草稿强制纳入,其余仅合并已审核来源,避免未审图泄露进正式表。
func (r *reviewRepository) runwayApprovedSiblingImages(ctx context.Context, excludeDraftID, brandID uint32, seasonCode, collectionType string) ([]model.BrandRunwayDraftImage, error) {
var drafts []model.BrandRunwayDraft
if err := r.db.WithContext(ctx).
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND status = ? AND is_deleted = 0 AND id <> ?", brandID, seasonCode, collectionType, model.DraftStatusApproved, excludeDraftID).
Find(&drafts).Error; err != nil {
return nil, err
}
out := make([]model.BrandRunwayDraftImage, 0)
for _, d := range drafts {
imgs, err := r.DraftImages(ctx, d.ID)
if err != nil {
return nil, err
}
out = append(out, imgs...)
}
return out, nil
}
// unionRunwayDraftImages 按 image URL 去重合并(七牛 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))
for _, im := range a {
if _, ok := seen[im.Image]; ok {
continue
}
seen[im.Image] = struct{}{}
out = append(out, im)
}
for _, im := range b {
if _, ok := seen[im.Image]; ok {
continue
}
seen[im.Image] = struct{}{}
out = append(out, im)
}
return out
}
// streetApprovedSiblingImages 同 city+year 已 approved 的其他街拍草稿图(多来源聚合)。
func (r *reviewRepository) streetApprovedSiblingImages(ctx context.Context, excludeDraftID uint32, city string, year uint16) ([]model.StreetSnapDraftImage, error) {
var drafts []model.StreetSnapDraft
if err := r.db.WithContext(ctx).
Where("city = ? AND year = ? AND status = ? AND is_deleted = 0 AND id <> ?", city, year, model.DraftStatusApproved, excludeDraftID).
Find(&drafts).Error; err != nil {
return nil, err
}
out := make([]model.StreetSnapDraftImage, 0)
for _, d := range drafts {
imgs, err := r.StreetDraftImages(ctx, d.ID)
if err != nil {
return nil, err
}
out = append(out, imgs...)
}
return out, nil
}
// unionStreetDraftImages 按 image URL 去重合并。当前草稿图优先。
func unionStreetDraftImages(a, b []model.StreetSnapDraftImage) []model.StreetSnapDraftImage {
seen := make(map[string]struct{}, len(a)+len(b))
out := make([]model.StreetSnapDraftImage, 0, len(a)+len(b))
for _, im := range a {
if _, ok := seen[im.Image]; ok {
continue
}
seen[im.Image] = struct{}{}
out = append(out, im)
}
for _, im := range b {
if _, ok := seen[im.Image]; ok {
continue
}
seen[im.Image] = struct{}{}
out = append(out, im)
}
return out
}
// ── 草稿图片删除 ────────────────────────────────────────────────────────────────
// SoftDeleteRunwayDraftImage 软删单张走秀草稿图片(审核阶段剔除坏图),并同步草稿 image_count。
// 不删七牛文件(与正式表 DeleteRunwayImage 行为一致:仅 DB 标记,避免误删被引用文件)。
func (r *reviewRepository) SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error {
if err := r.db.WithContext(ctx).
Model(&model.BrandRunwayDraftImage{}).
Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
return r.db.WithContext(ctx).
Exec("UPDATE brand_runway_draft SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error
}
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。
func (r *reviewRepository) SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error {
if err := r.db.WithContext(ctx).
Model(&model.StreetSnapDraftImage{}).
Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
return r.db.WithContext(ctx).
Exec("UPDATE street_snap_draft SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error
}
// GetDraftImageKey 取单张草稿图片的七牛 key(删除前清理用);不存在返回空串。
// kind 决定查哪张草稿图片表(runway / street);未知 kind 返回 ErrNotFound。
func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error) {
switch kind {
case dto.IngestKindRunway:
var im model.BrandRunwayDraftImage
err := r.db.WithContext(ctx).Where("id = ? AND draft_id = ?", imgID, draftID).First(&im).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
if err != nil {
return "", err
}
return im.Image, nil
case dto.IngestKindStreet:
var im model.StreetSnapDraftImage
err := r.db.WithContext(ctx).Where("id = ? AND draft_id = ?", imgID, draftID).First(&im).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
if err != nil {
return "", err
}
return im.Image, nil
default:
return "", ErrNotFound
}
}