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

23
internal/dto/crawl.go Normal file
View File

@ -0,0 +1,23 @@
package dto
// CrawlBrand 爬虫取任务接口返回的单个品牌。
// brand_uid 为 hashid 编码串(与对外一致),spider 直接拿它上送 ingest,无需自己编码;
// name 为英文品牌名,供 spider 拼接到 vogue 抓取 URL(如 /fashion-shows/designer/<slug>)。
type CrawlBrand struct {
BrandUID string `json:"brand_uid"`
Name string `json:"name"`
}
// CrawlExistsRequest 图集预检请求:一次问一批 source_url 是否已爬取过。
// 设计为批量而非逐个,是因为爬虫在拿到列表页后能一次拼出几十上百个图集链接,
// 逐个问会退化成 N 次 HTTP 往返,反而比直接抓取更慢。
type CrawlExistsRequest struct {
SourceURLs []string `json:"source_urls"`
}
// CrawlExistsResponse 图集预检响应。existing 为「已爬取过、无需再抓」的 source_url 列表;
// 未出现在其中的即认为需要抓取。
type CrawlExistsResponse struct {
Existing []string `json:"existing"` // 已爬取过的 source_url,爬虫应跳过
ExistingCount int `json:"existing_count"` // 命中数量,便于爬虫打日志观察命中率
}

34
internal/dto/ingest.go Normal file
View File

@ -0,0 +1,34 @@
package dto
// 入库类型(payload.Kind)。
const (
IngestKindRunway = "runway" // 走秀(默认,需 brand_uid)
IngestKindStreet = "street" // 街拍(无品牌,需 city/title)
)
// RunwayIngest 爬虫上报的单场载荷(ingest_jobs.payload 的 JSON 结构)。
//
// 设计:爬虫只传「元数据 + 图片 source_url 列表」,不传图、不直写业务表;
// 下载与存储上传由后台 worker 完成。
//
// 通用字段:
// - Kind: runway | street(决定 worker 走哪条晋升管线;缺省 runway)
// - SourceURL: 采集来源链接(去重 / 溯源,必填)
// - Images: 图片原始 URL 列表(worker 下载)
//
// runway 专属:BrandUID(品牌 hashid 编码,必填)、TitleEn/TitleCn、Description*、Year、Season、CollectionType。
// street 专属:City(地区/城市)、TitleEn(单标题)。
type RunwayIngest struct {
Kind string `json:"kind"` // runway | street,缺省 runway
BrandUID string `json:"brand_uid"` // 品牌编码 id(hashid),runway 必填
TitleEn string `json:"title_en"` // 英文标题(runway 优先;street 作为单标题)
TitleCn string `json:"title_cn"` // 中文标题(可空)
DescriptionEn string `json:"description_en"` // 英文描述(可空)
DescriptionCn string `json:"description_cn"` // 中文描述(可空)
Year uint16 `json:"year"` // 年份,如 2026
Season string `json:"season"` // spring / fall
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
City string `json:"city"` // 地区/城市(street 用)
SourceURL string `json:"source_url"` // 采集来源链接(去重 / 溯源,必填)
Images []string `json:"images"` // 图片原始 URL 列表(worker 下载)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,102 @@
package handler
import (
"errors"
"net/http"
"sort"
"strconv"
"fashionapi/internal/dto"
"fashionapi/internal/service"
"github.com/gin-gonic/gin"
)
// IngestHandler 爬虫上报入口(:8092 的 /admin/internal/ingest)。
//
// 安全:不挂后台登录 cookie 中间件,改走 HMAC 验签中间件(middleware.IngestAuth);
// 因此多节点爬虫可直连上报,无需回环绑定。接口本身很薄:验签 → Submit 入队 → 立即 202。
type IngestHandler struct {
ingest *service.IngestService
brands service.BrandService
}
// NewIngestHandler 创建入库 handler。
func NewIngestHandler(ingest *service.IngestService, brands service.BrandService) *IngestHandler {
return &IngestHandler{ingest: ingest, brands: brands}
}
// Submit 接收单场走秀上报,入队后返回 202 Accepted(异步处理)。
func (h *IngestHandler) Submit(c *gin.Context) {
var p dto.RunwayIngest
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()})
return
}
id, err := h.ingest.Submit(c.Request.Context(), p)
if err != nil {
// 业务错误(缺字段 / 未知品牌等)映射为 4xx,其余 5xx
if errors.Is(err, service.ErrBrandNotFound) || isClientErr(err) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "enqueue failed"})
return
}
c.JSON(http.StatusAccepted, gin.H{"job_id": id, "status": "queued"})
}
// CrawlBrands 爬虫取任务接口(GET /admin/internal/crawl/brands,复用 IngestAuth HMAC 验签)。
// 返回所有可抓取品牌的 brand_uid(hashid)+ 英文品牌名,供 spider 拼 vogue 抓取 URL 并上送 ingest。
func (h *IngestHandler) CrawlBrands(c *gin.Context) {
// 支持 ?brand=<numeric id> 单品牌调试:解析失败或非正数时回落全量。
var brandID uint32
if v := c.Query("brand"); v != "" {
if id, err := strconv.ParseUint(v, 10, 32); err == nil {
brandID = uint32(id)
}
}
brands, err := h.brands.CrawlTasks(c.Request.Context(), brandID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "fetch crawl tasks failed"})
return
}
c.JSON(http.StatusOK, gin.H{"brands": brands})
}
// CrawlExists 图集预检接口(POST /admin/internal/crawl/exists,复用 IngestAuth HMAC 验签)。
//
// 爬虫在抓详情页之前批量问「这些 source_url 是否已爬取过」,跳过已存在的图集,
// 省掉「抓详情页 → 提取图片 URL → 上报 → worker 判重后丢弃」这一整套无效动作。
// 判定条件与 worker 判重一致,因此跳过是安全的:不会漏抓,也不会重复抓。
func (h *IngestHandler) CrawlExists(c *gin.Context) {
var req dto.CrawlExistsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()})
return
}
exists, err := h.ingest.ExistsSourceURLs(c.Request.Context(), req.SourceURLs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
existing := make([]string, 0, len(exists))
for u := range exists {
existing = append(existing, u)
}
// 排序保证输出稳定,便于日志比对与测试断言。
sort.Strings(existing)
c.JSON(http.StatusOK, dto.CrawlExistsResponse{
Existing: existing,
ExistingCount: len(existing),
})
}
func isClientErr(err error) bool {
// service 层 NewError 带 http 状态码;这里简单按消息前缀判断,避免暴露内部类型。
if err == nil {
return false
}
msg := err.Error()
return len(msg) > 0 && (msg == "source_url required" || msg == "brand_uid required")
}

View File

@ -0,0 +1,56 @@
package middleware
import (
"io"
"net/http"
"strings"
"fashionapi/internal/pkg/hmac"
"fashionapi/internal/repository"
"github.com/gin-gonic/gin"
)
// IngestAuthConfig 爬虫上报接口验签所需依赖。
type IngestAuthConfig struct {
Secret string
TTL int
Nonces repository.IngestRepository // 复用的 nonce 表(与任务队列表同库)
}
// IngestAuth 校验爬虫上报的 HMAC 签名 + nonce 防重放。
//
// 流程:读原始 body → 校验 X-Signature / X-Timestamp / X-Nonce(签名 + ±TTL 时间戳)
// → 用 nonce 表 ReserveNonce 去重(已存在即重放,返回 409)→ 通过则放行。
// 失败一律返回 401(签名/时间戳)或 409(重放),不泄露具体原因。
func IngestAuth(cfg IngestAuthConfig) gin.HandlerFunc {
return func(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "read body failed"})
return
}
// 还原 body,供后续 handler 再读
c.Request.Body = io.NopCloser(strings.NewReader(string(body)))
sig := c.GetHeader("X-Signature")
ts := c.GetHeader("X-Timestamp")
nonce := c.GetHeader("X-Nonce")
if !hmac.Verify(cfg.Secret, string(body), sig, ts, nonce, cfg.TTL) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"})
return
}
ok, err := cfg.Nonces.ReserveNonce(c.Request.Context(), nonce)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "nonce store error"})
return
}
if !ok {
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": "replay detected"})
return
}
c.Next()
}
}

View File

@ -0,0 +1,57 @@
package model
// IngestJob 爬虫上报的走秀入库任务队列。
//
// 设计:爬虫 POST 到 :8092 的 ingest 接口(HMAC 验签后)只做「入队 + 立即 202」,
// 真正的去重 / 补分类 / 下载图 / 写正式表 由后台 worker 异步处理,
// 因此接口本身很薄、不会被慢任务(尤其是 OSS 上传)拖垮。
//
// 并发安全靠 MySQL 的 `SELECT ... FOR UPDATE SKIP LOCKED`:多个 worker 同时
// Claim 不会抢到同一条,未提交的行对其它 worker 不可见。
type IngestJob struct {
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
LockedAt uint32 `gorm:"column:locked_at" json:"locked_at"` // worker 领取时间;用于超时兜底回收
SourceURL string `gorm:"column:source_url" json:"source_url"`
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"` // pending|processing|done|failed
Attempts uint8 `gorm:"column:attempts;not null;default:0" json:"attempts"`
Payload string `gorm:"column:payload;type:text" json:"payload"` // 原始 JSON(RunwayIngest 或 MediaCleanupPayload)
LastError string `gorm:"column:last_error;type:text" json:"last_error"`
Kind string `gorm:"column:kind;size:16;not null;default:'crawl'" json:"kind"` // crawl|media_cleanup
NextAttemptAt uint32 `gorm:"column:next_attempt_at;not null;default:0" json:"next_attempt_at"` // 重试退避:到达该 unix 时间才可被领取
}
// TableName 指定队列表名。
func (IngestJob) TableName() string { return "ingest_jobs" }
// IngestJob 状态常量。
const (
IngestStatusPending = "pending"
IngestStatusProcessing = "processing"
IngestStatusDone = "done"
IngestStatusFailed = "failed"
)
// IngestJob 任务类型(kind 列):区分「爬虫入库」与「清理七牛孤儿图」两条管线。
const (
IngestKindCrawl = "crawl" // 爬虫上报的走秀/街拍入库
IngestKindMediaCleanup = "media_cleanup" // 删除图集时异步清理七牛孤儿文件
)
// IngestMaxAttempts 单条任务最大处理次数(含首次)。达上限仍未成功则置 failed,需后台手动重试。
const IngestMaxAttempts = 3
// IngestStuckTimeoutSec 任务「卡死」判定阈值:worker 领取后 processing 状态持续超过该秒数,
// 视为 worker 崩溃/被杀遗留,由 Claim 兜底回收为 pending 重领。下载图/传图正常远低于此值。
const IngestStuckTimeoutSec = 300
// IngestRetryBackoff 返回第 attempts 次失败后的退避秒数(指数退避,封顶 600s)。
// attempts 从 1 起算:30s / 60s / 120s。用于失败任务的自动重试调度。
func IngestRetryBackoff(attempts int) int {
d := 30 * (1 << uint(attempts-1))
if d > 600 {
d = 600
}
return d
}

View File

@ -0,0 +1,14 @@
package model
// IngestNonce 爬虫上报的一次性随机串,用于防重放。
//
// 每次合法请求携带唯一 X-Nonce;后台先查库,若存在则拒绝(409 重放),
// 否则放行并写入。nonce 本身随机且一次性,空间可忽略;
// created_at 用于定期清理(如保留 7 天)以防表无限增长。
type IngestNonce struct {
Nonce string `gorm:"primaryKey;column:nonce;size:64" json:"nonce"`
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
}
// TableName 指定 nonce 表名。
func (IngestNonce) TableName() string { return "ingest_nonces" }

View File

@ -0,0 +1,54 @@
package model
// BrandRunwayDraft 走秀草稿(爬虫入库待审核)。
//
// 爬虫 worker 处理完 ingest_jobs 后不直写正式表,而是写入本表并置为 pending;
// 后台审核通过(Approve)后再晋升(SaveRunwayFromDraft)到 brand_runway 正式表。
// 字段与 brand_runway 对齐(含 review 专用字段),便于审核页直接预览/微调。
type BrandRunwayDraft struct {
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
JobID uint32 `gorm:"column:job_id" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源)
BrandID uint32 `gorm:"column:brand_id" json:"brand_id"` // 品牌数字主键
TitleEn string `gorm:"column:title_en" json:"title_en"`
TitleCn string `gorm:"column:title_cn" json:"title_cn"`
DescriptionEn string `gorm:"column:description_en" json:"description_en"`
DescriptionCn string `gorm:"column:description_cn" json:"description_cn"`
Year uint16 `gorm:"column:year" json:"year"`
Season string `gorm:"column:season" json:"season"`
CollectionType string `gorm:"column:collection_type" json:"collection_type"` // rtw/menswear/couture/resort/pre_fall
SeasonCode string `gorm:"column:season_code" json:"season_code"` // SS26/FW25/RES26/PF25
Cover string `gorm:"column:cover" json:"cover"`
SourceURL string `gorm:"column:source_url" json:"source_url"`
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
Status string `gorm:"column:status" json:"status"` // pending | approved | rejected
Reviewer string `gorm:"column:reviewer" json:"reviewer"` // 审核人(admin 账号)
RejectReason string `gorm:"column:reject_reason" json:"reject_reason"`
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
}
// TableName 指定草稿主表名。
func (BrandRunwayDraft) TableName() string { return "brand_runway_draft" }
// BrandRunwayDraftImage 草稿图片(对应 brand_runway_draft_images)。
type BrandRunwayDraftImage struct {
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
DraftID uint32 `gorm:"column:draft_id" json:"draft_id"`
Image string `gorm:"column:image" json:"image"`
Name string `gorm:"column:name" json:"name"`
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"`
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
}
// TableName 指定草稿图片表名。
func (BrandRunwayDraftImage) TableName() string { return "brand_runway_draft_images" }
// 草稿状态常量。
const (
DraftStatusPending = "pending"
DraftStatusApproved = "approved"
DraftStatusRejected = "rejected"
)

View File

@ -0,0 +1,43 @@
package model
// StreetSnapDraft 街拍草稿(爬虫入库待审核)。
//
// 与 brand_runway_draft 同构的审核流程:爬虫 worker 处理完 ingest_jobs 后写入本表并置
// pending;后台审核通过(Approve)后再晋升(SaveStreetSnapFromDraft)到 street_snap 正式表。
//
// 字段对齐 street_snap 正式表(单 Title、有 City、无 brand_id、无 season 系列列),
// 外加审核专用字段,便于审核页直接预览/微调。
type StreetSnapDraft struct {
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
JobID uint32 `gorm:"column:job_id" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源)
Title string `gorm:"column:title" json:"title"` // 单标题(街拍无双语)
Year uint16 `gorm:"column:year" json:"year"`
City string `gorm:"column:city" json:"city"` // 地区/城市,'' 表示未归类
Cover string `gorm:"column:cover" json:"cover"`
SourceURL string `gorm:"column:source_url" json:"source_url"`
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
Status string `gorm:"column:status" json:"status"` // pending | approved | rejected
Reviewer string `gorm:"column:reviewer" json:"reviewer"` // 审核人(admin 账号)
RejectReason string `gorm:"column:reject_reason" json:"reject_reason"`
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
}
// TableName 指定草稿主表名。
func (StreetSnapDraft) TableName() string { return "street_snap_draft" }
// StreetSnapDraftImage 街拍草稿图片(对应 street_snap_draft_images)。
type StreetSnapDraftImage struct {
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
DraftID uint32 `gorm:"column:draft_id" json:"draft_id"`
Image string `gorm:"column:image" json:"image"`
Name string `gorm:"column:name" json:"name"`
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"`
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
}
// TableName 指定草稿图片表名。
func (StreetSnapDraftImage) TableName() string { return "street_snap_draft_images" }

60
internal/pkg/hmac/hmac.go Normal file
View File

@ -0,0 +1,60 @@
// Package hmac 提供爬虫 → 后台 ingest 接口的 HMAC-SHA256 请求签名与校验。
//
// 设计目标(与公开接口「前端 JS 签名」区分):
// - 这里面向「服务端到服务端」的爬虫上报,密钥绝不下发到任何前端 bundle,
// 只存在于爬虫配置与后台 INGEST_SECRET 环境变量,安全性高得多。
// - 通过 X-Signature / X-Timestamp / X-Nonce 三头防篡改 + 防重放:
// - X-Timestamp 容忍窗口(默认 ±5 分钟)挡掉过期请求;
// - X-Nonce 一次性随机串,由后台入库去重挡掉重放(见 repository.IngestRepository 的 nonce 表)。
//
// 签名串拼接:HMAC_SHA256(secret, timestamp + "." + nonce + "." + bodyRaw)
// bodyRaw 是请求体的原始字节(未编码),保证签名与服务端收到的字节严格一致。
package hmac
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
// DefaultTTL 签名时间戳默认容忍窗口(秒)。
const DefaultTTL = 300
// Sign 生成签名:对 timestamp.nonce.body 做 HMAC-SHA256,返回十六进制串。
func Sign(secret, timestamp, nonce, body string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write([]byte(nonce))
mac.Write([]byte("."))
mac.Write([]byte(body))
return hex.EncodeToString(mac.Sum(nil))
}
// Verify 校验请求签名,同时检查时间戳窗口。
//
// 返回 (ok, error):ok=false 表示签名或时间戳不通过;error 仅用于内部异常(理论上不会返回)。
// 注:nonce 防重放不在此处判断,交由调用方(中间件 / 仓储)查库,
// 因为 nonce 是否重复依赖持久化状态,且失败时应返回 409 而非 401。
func Verify(secret, body, sigHeader, tsHeader, nonceHeader string, ttl int) bool {
if sigHeader == "" || tsHeader == "" || nonceHeader == "" {
return false
}
if ttl <= 0 {
ttl = DefaultTTL
}
ts, err := strconv.ParseInt(tsHeader, 10, 64)
if err != nil {
return false
}
now := time.Now().Unix()
if diff := now - ts; diff > int64(ttl) || diff < -int64(ttl) {
return false
}
expected := Sign(secret, tsHeader, nonceHeader, body)
// 定长比较防时序侧信道
return hmac.Equal([]byte(expected), []byte(strings.TrimSpace(sigHeader)))
}

View File

@ -0,0 +1,78 @@
package hmac
import (
"testing"
"time"
)
func TestSignVerifyRoundTrip(t *testing.T) {
secret := "topsecret"
body := `{"brand_uid":"001DESke","source_url":"https://vogue.com/x"}`
ts := tsNow()
nonce := "abc123nonce"
sig := Sign(secret, ts, nonce, body)
if !Verify(secret, body, sig, ts, nonce, DefaultTTL) {
t.Fatal("valid signature should verify")
}
// 篡改 body → 失败
if Verify(secret, body+"x", sig, ts, nonce, DefaultTTL) {
t.Fatal("tampered body should fail")
}
// 错误密钥 → 失败
if Verify("wrong", body, sig, ts, nonce, DefaultTTL) {
t.Fatal("wrong secret should fail")
}
// 缺头 → 失败
if Verify(secret, body, "", ts, nonce, DefaultTTL) {
t.Fatal("missing header should fail")
}
}
func TestVerifyRejectsStaleTimestamp(t *testing.T) {
secret := "s"
body := "{}"
// 早于窗口
old := timeNowMinus(600)
nonce := "n1"
sig := Sign(secret, old, nonce, body)
if Verify(secret, body, sig, old, nonce, DefaultTTL) {
t.Fatal("stale timestamp should be rejected")
}
// 晚于窗口(未来太多)
future := timeNowPlus(600)
sig2 := Sign(secret, future, nonce, body)
if Verify(secret, body, sig2, future, nonce, DefaultTTL) {
t.Fatal("future timestamp beyond window should be rejected")
}
}
// 简易时间助手(避免直接依赖 time.Now 的不可控)
func tsNow() string { return itoa(int64(time.Now().Unix())) }
func timeNowMinus(sec int) string {
return itoa(int64(time.Now().Unix()) - int64(sec))
}
func timeNowPlus(sec int) string {
return itoa(int64(time.Now().Unix()) + int64(sec))
}
func itoa(v int64) string {
if v == 0 {
return "0"
}
neg := v < 0
if neg {
v = -v
}
buf := [20]byte{}
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}

View File

@ -0,0 +1,133 @@
// Package imgurl 把数据库里存储的图片存储值拼装为对外可访问的完整 URL。
//
// 存储约定(见迁移 007):库里只存「七牛 key」(如 runway/abc.jpg)或「本地相对路径」
// (如 /uploads/runway/x.jpg),绝不存完整 URL。这样做两个好处:
// 1. 换 CDN 域名只改配置,无需迁移全表;
// 2. 做「普通 / 高清」分级时无需从 URL 反向解析 key。
//
// 分级与安全(付费墙):
// - 高清(hd=true,仅 VIP):私有空间下载签名 URL(base_url/key-StyleVip,如 key-vip),限时有效;
// 免费/未登录用户拿不到 VIP 样式 key,即使从别处得到 key,因无签名也会被七牛拒绝(403)。
// - 普通(hd=false,所有人):base_url/key-StyleNormal(如 key-free)的私有空间签名 URL,长过期(供 SSG 静态页与 CDN 缓存)。
// - 本地兜底 /uploads/*:原样返回(前端 toAbs 拼 host)。
// - 外链 http(s):原样返回(历史遗留 / vogue 等,无法套七牛样式,签名无意义)。
//
// 七牛私有空间下载签名:对 "base_url/key?e=<deadline>" 做 HMAC-SHA1(SK),urlsafe base64 后拼
// "AK:sign" 作为 token。签名覆盖完整 path(含 -StyleNormal / -StyleVip 后缀),七牛校验通过才返回资源。
// 前置:bucket 须在七牛控制台设为「私有」,并把 base_url 改为自定义 HTTPS 域名。
package imgurl
import (
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"fmt"
"strings"
"time"
)
// Composer 图片 URL 拼装器。
type Composer struct {
// BaseURL 七牛 bucket 绑定域名,如 https://cdn.toom-studio.com(末尾无斜杠)。
BaseURL string
// StyleNormal 普通图七牛命名样式名(对应七牛控制台创建的样式),如 "free"。
// 访问即 base_url/key-StyleNormal。务必与控制台样式名一致(如 free 或 free.webp)。
StyleNormal string
// StyleVip VIP 高清七牛命名样式名(对应七牛控制台创建的样式),如 "vip"。
// 访问即 base_url/key-StyleVip。VIP 用户走此样式;务必与控制台样式名一致。
StyleVip string
// LocalBase 本地兜底图片前缀;通常空——本地 /uploads 路径由前端 toAbs 拼 host。
LocalBase string
// AK/SK 七牛 AccessKey/SecretKey,用于私有空间下载签名。未配置时回落为公开 URL。
AK string
SK string
// SignTTLNormal 普通/压缩图(含命名样式 -free)签名过期秒;长过期供 SSG 静态页与 CDN 缓存。
SignTTLNormal int64
// SignTTLHD VIP 高清原图签名过期秒;短过期使泄漏窗口小。
SignTTLHD int64
}
// New 构造 Composer。styleNormal 缺省回落 "free";styleVip 缺省回落 "vip";两个 TTL 缺省回落 31536000 / 3600。
func New(baseURL, styleNormal, styleVip, localBase, ak, sk string, ttlNormal, ttlHD int64) *Composer {
if styleNormal == "" {
styleNormal = "free"
}
if styleVip == "" {
styleVip = "vip"
}
if ttlNormal <= 0 {
ttlNormal = 31536000
}
if ttlHD <= 0 {
ttlHD = 3600
}
return &Composer{
BaseURL: strings.TrimRight(baseURL, "/"),
StyleNormal: styleNormal,
StyleVip: styleVip,
LocalBase: strings.TrimRight(localBase, "/"),
AK: ak,
SK: sk,
SignTTLNormal: ttlNormal,
SignTTLHD: ttlHD,
}
}
// sign 生成七牛私有空间下载签名 URL。
//
// rawKey 是待访问的对象路径(可能含 -StyleNormal 后缀)。签名字符串为
// "base_url/rawKey?e=<deadline>",对其做 HMAC-SHA1(SK) 后 urlsafe base64 得到 token。
//
// 无 AK/SK 或 BaseURL 时回落为公开 URL(兼容未配置七牛的开发态:公开空间会忽略 token 直接返回)。
func (c *Composer) sign(rawKey string, ttl int64) string {
if c.BaseURL == "" {
return rawKey
}
if c.AK == "" || c.SK == "" {
return c.BaseURL + "/" + rawKey
}
deadline := time.Now().Unix() + ttl
toSign := fmt.Sprintf("%s/%s?e=%d", c.BaseURL, rawKey, deadline)
mac := hmac.New(sha1.New, []byte(c.SK))
mac.Write([]byte(toSign))
enc := base64.URLEncoding.EncodeToString(mac.Sum(nil))
return fmt.Sprintf("%s&token=%s:%s", toSign, c.AK, enc)
}
// Compose 把存储值拼成可访问 URL。
//
// - stored 为空:返回 ""
// - 本地兜底 /uploads/*:原样返回(前端 toAbs 拼 host)
// - 已是完整 http(s) URL(历史遗留 / 外链图,如 vogue):hd 原样返回;
// normal 尝试从 base_url 还原 key 再套样式签名,还原失败原样返回(外链无法套样式)
// - 七牛 key:hd → 原图签名 URL(短过期);normal → 普通图(命名样式)签名 URL(长过期)
//
// 这样免费/未登录用户永远拿不到「不带签名的原图 URL」;VIP 拿到的是限时签名原图 URL。
func (c *Composer) Compose(stored string, hd bool) string {
stored = strings.TrimSpace(stored)
if stored == "" {
return ""
}
// 本地兜底路径:由前端拼绝对地址,原样返回。
if strings.HasPrefix(stored, "/uploads/") {
return stored
}
// 已是完整 URL(历史遗留 / 外链图)。
if strings.HasPrefix(stored, "http://") || strings.HasPrefix(stored, "https://") {
if hd {
return stored
}
// 同域七牛 URL:剥掉 base_url 还原 key,再套普通样式签名。
if c.BaseURL != "" {
if key, ok := strings.CutPrefix(stored, c.BaseURL+"/"); ok {
return c.sign(key+"-"+c.StyleNormal, c.SignTTLNormal)
}
}
return stored // 外链图无法套样式,原样返回
}
// 七牛 key:VIP 高清(-StyleVip 命名样式)签名(短过期);免费图(-StyleNormal 命名样式)签名(长过期)。
if hd {
return c.sign(stored+"-"+c.StyleVip, c.SignTTLHD)
}
return c.sign(stored+"-"+c.StyleNormal, c.SignTTLNormal)
}

View File

@ -0,0 +1,33 @@
package imgurl
import (
"strings"
"testing"
)
// TestVipStyle 验证:免费(free)用 -free 命名样式、VIP 用 -vip 命名样式,且均带私有空间签名。
func TestVipStyle(t *testing.T) {
c := New("http://toom-studio.23cm.cn", "free", "vip", "", "AK_TEST", "SK_TEST", 31536000, 3600)
free := c.Compose("runway/abc123.jpg", false)
vip := c.Compose("runway/abc123.jpg", true)
t.Logf("free URL = %s", free)
t.Logf("vip URL = %s", vip)
if free == "" || vip == "" {
t.Fatal("URL 不应为空")
}
if !strings.Contains(free, "-free") {
t.Fatalf("免费图应拼 -free 命名样式,实际: %s", free)
}
if !strings.Contains(vip, "-vip") {
t.Fatalf("VIP 图应拼 -vip 命名样式,实际: %s", vip)
}
if !strings.Contains(free, "token=") || !strings.Contains(vip, "token=") {
t.Fatal("两种图都必须带私有空间下载签名 token=")
}
if strings.Contains(vip, "-free") {
t.Fatal("VIP 图不应混入 -free")
}
}

View File

@ -0,0 +1,71 @@
// Package season 走秀季节 / 年份 → season_code 的推导与解析辅助。
//
// season_code 是对外与排序共用的紧凑编码:SS26 / FW25 / RES26 / PF25,
// 规则:前缀由 collection_type(rtw/menswear→SS/FW;resort→RES;pre_fall→PF)
// 或 season(spring→SS / fall→FW)决定,后缀为两位年份。
// 历史代码 vogue.go 漏填 season_code 是已知 bug,这里集中推导逻辑,worker 与回填脚本复用。
package season
import (
"strconv"
)
// CollectionType / Season 取值白名单。
const (
ColRTW = "rtw"
ColMenswear = "menswear"
ColCouture = "couture"
ColResort = "resort"
ColPreFall = "pre_fall"
SeasonSpring = "spring"
SeasonFall = "fall"
)
// Derive 由年份 + 系列/季节推导 season_code(如 2026 + spring → "SS26")。
//
// 优先级:collection_type 先在(resort→RES、pre_fall→PF);否则用 season
// (spring→SS、fall→FW);二者皆空或无法识别时回落 "SS"。
// year 为 0 时返回空串(未知年份无法编码)。
func Derive(year uint16, collectionType, season string) string {
if year == 0 {
return ""
}
prefix := prefixOf(collectionType, season)
return prefix + twoDigit(year)
}
func prefixOf(collectionType, season string) string {
switch collectionType {
case ColResort:
return "RES"
case ColPreFall:
return "PF"
case ColCouture:
// 高定沿用秋冬编码(FW),与排序优先级一致
return "FW"
case ColMenswear:
return "FW"
case ColRTW:
return "SS"
}
switch season {
case SeasonFall:
return "FW"
case SeasonSpring:
return "SS"
}
return "SS"
}
// twoDigit 取年份后两位(2026 → "26")。
func twoDigit(year uint16) string {
if year >= 2000 {
year -= 2000
}
s := strconv.Itoa(int(year))
if len(s) > 2 {
s = s[len(s)-2:]
}
return s
}

View File

@ -0,0 +1,28 @@
package season
import "testing"
func TestDerive(t *testing.T) {
cases := []struct {
year uint16
col string
season string
want string
}{
{2026, ColRTW, SeasonSpring, "SS26"},
{2025, "", SeasonFall, "FW25"},
{2026, ColResort, "", "RES26"},
{2026, ColPreFall, "", "PF26"},
{2025, ColMenswear, "", "FW25"},
{2025, ColCouture, "", "FW25"},
{2026, "", "", "SS26"}, // 二者皆空回落 SS
{0, ColRTW, SeasonSpring, ""}, // 未知年份无法编码
{2099, ColRTW, SeasonSpring, "SS99"},
}
for _, c := range cases {
got := Derive(c.year, c.col, c.season)
if got != c.want {
t.Fatalf("Derive(%d,%q,%q)=%q want %q", c.year, c.col, c.season, got, c.want)
}
}
}

View File

@ -0,0 +1,196 @@
package storage
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"path"
"strings"
"time"
)
// QiniuUploader 通过七牛云表单直传(标准库实现,无第三方 SDK 依赖)。
//
// 上传凭证算法(与官方 SDK 一致):
//
// putPolicy = {"scope":bucket,"deadline":unix+3600,"insertOnly":1}
// encPolicy = base64.URLEncoding(putPolicy) // urlsafe,保留 '=' 填充
// sign = HMAC-SHA1(SK, encPolicy)
// encSign = base64.URLEncoding(sign) // 同样保留填充
// token = AK + ":" + encSign + ":" + encPolicy
// 注意:必须用 base64.URLEncoding(带 '=' 填充),不能用 RawURLEncoding,否则服务端报 "bad token"。
//
// 然后以 multipart/form-data POST 到对应区域的上传域名,表单含 token / key / file。
type QiniuUploader struct {
ak, sk, bucket, baseURL string
upHost string
client *http.Client
}
// NewQiniuUploader 构造七牛上传器。
// - zone: z0=华东 z1=华北 z2=华南 na0=北美 as0=新加坡
// - baseURL: bucket 绑定的公开访问域名(如 https://cdn.toom-studio.com),用于拼出图片 URL;
// 为空时仅返回对象 key(调用方需另行拼接域名)。
func NewQiniuUploader(ak, sk, bucket, zone, baseURL string) *QiniuUploader {
return &QiniuUploader{
ak: ak,
sk: sk,
bucket: bucket,
baseURL: strings.TrimRight(baseURL, "/"),
upHost: upHostByZone(zone),
client: &http.Client{Timeout: 60 * time.Second},
}
}
// upHostByZone 把区域映射为七牛上传域名。
func upHostByZone(zone string) string {
switch zone {
case "z0":
return "https://up-z0.qiniup.com"
case "z1":
return "https://up-z1.qiniup.com"
case "z2":
return "https://up-z2.qiniup.com"
case "na0":
return "https://up-na0.qiniup.com"
case "as0":
return "https://up-as0.qiniup.com"
default:
return "https://up-z1.qiniup.com"
}
}
// Enabled 任一凭证缺失即视为未启用。
func (q *QiniuUploader) Enabled() bool {
return q.ak != "" && q.sk != "" && q.bucket != ""
}
// makeToken 生成七牛上传凭证。
func (q *QiniuUploader) makeToken() (string, error) {
putPolicy := map[string]any{
"scope": q.bucket,
"deadline": time.Now().Add(time.Hour).Unix(),
"insertOnly": 1,
}
b, err := json.Marshal(putPolicy)
if err != nil {
return "", err
}
// 注意:七牛 token 使用 urlsafe base64 但保留 '=' 填充(base64.URLEncoding),
// 与官方 SDK 一致;不能用 RawURLEncoding,否则服务端校验签名失败报 "bad token"。
encPolicy := base64.URLEncoding.EncodeToString(b)
mac := hmac.New(sha1.New, []byte(q.sk))
mac.Write([]byte(encPolicy))
encSign := base64.URLEncoding.EncodeToString(mac.Sum(nil))
return q.ak + ":" + encSign + ":" + encPolicy, nil
}
// Upload 把 data 直传到七牛 bucket 的 key,返回可访问 URL。
func (q *QiniuUploader) Upload(ctx context.Context, key string, data []byte, contentType string) (string, error) {
token, err := q.makeToken()
if err != nil {
return "", err
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
if err := mw.WriteField("token", token); err != nil {
return "", err
}
if err := mw.WriteField("key", key); err != nil {
return "", err
}
fw, err := mw.CreateFormFile("file", path.Base(key))
if err != nil {
return "", err
}
if _, err := fw.Write(data); err != nil {
return "", err
}
if err := mw.Close(); err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, q.upHost, &buf)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := q.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("qiniu upload failed: status=%d body=%s", resp.StatusCode, string(body))
}
// 成功返回 {"hash":"...","key":"..."};失败返回 {"error":"..."}
var out struct {
Key string `json:"key"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &out); err == nil && out.Error != "" {
return "", fmt.Errorf("qiniu error: %s", out.Error)
}
if q.baseURL != "" {
return q.baseURL + "/" + strings.TrimLeft(key, "/"), nil
}
return key, nil
}
// rsHost 七牛管理 API 域名(全局,不分区域)。删除对象走此端点。
const rsHost = "rs.qiniu.com"
// Delete 删除七牛 bucket 中的对象(按 key)。仅当引用计数归零时由调用方触发,
// 避免误删仍被其他图集引用的文件(七牛 key 为 sha1 内容寻址,同名图跨图集共享 key)。
//
// 管理凭证(QBox 方案,与官方 SDK 一致):
//
// entry = base64.URLEncoding(bucket + ":" + key) // 带 '=' 填充
// path = "/delete/" + entry
// signing = path + "\n" // 仅 Path + 换行,不含 Method/Host/Content-Type
// sign = HMAC-SHA1(SK, signing)
// token = AK + ":" + base64.URLEncoding(sign)
// Authorization: QBox <token> // 注意前缀是 QBox(下载凭证才是 Qiniu)
func (q *QiniuUploader) Delete(ctx context.Context, key string) error {
if !q.Enabled() {
return nil
}
// 七牛管理凭证(QBox)签名串仅为 Path + "\n",前缀 "QBox";与下载凭证(imgurl.sign,前缀 "Qiniu")不同。
entry := base64.URLEncoding.EncodeToString([]byte(q.bucket + ":" + key))
path := "/delete/" + entry
signing := path + "\n"
mac := hmac.New(sha1.New, []byte(q.sk))
mac.Write([]byte(signing))
token := q.ak + ":" + base64.URLEncoding.EncodeToString(mac.Sum(nil))
url := "https://" + rsHost + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "QBox "+token)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := q.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("qiniu delete failed: status=%d body=%s", resp.StatusCode, string(body))
}
return nil
}

View File

@ -0,0 +1,55 @@
package storage
import (
"context"
"fmt"
"strings"
"testing"
"time"
"fashionapi/internal/config"
)
// TestQiniuPurgeRoundTrip 真实验证七牛删除能力(管理凭证签名是否正确):
// 上传一个临时 key → 删除(期望 200)→ 再删一次(期望 612 资源不存在,证明已被删)。
// 若管理 token 签名错误,七牛会返回 401(invalid token),测试即失败。
// 仅在七牛启用且能连通时运行;否则跳过。
func TestQiniuPurgeRoundTrip(t *testing.T) {
cfg, err := config.Load("../../../configs/config.yml")
if err != nil {
t.Skipf("加载配置失败,跳过: %v", err)
}
if !cfg.Qiniu.Enabled {
t.Skip("七牛未启用,跳过删除验证")
}
up := NewQiniuUploader(cfg.Qiniu.AK, cfg.Qiniu.SK, cfg.Qiniu.Bucket, cfg.Qiniu.Zone, "")
key := fmt.Sprintf("runway/_purge_verify_%d.bin", time.Now().UnixNano())
ctx := context.Background()
// 1) 上传临时对象(任意字节即可,七牛不校验内容格式)
if _, err := up.Upload(ctx, key, []byte("purge-verify-test-content"), "application/octet-stream"); err != nil {
t.Fatalf("上传临时对象失败(无法继续验证删除): %v", err)
}
t.Logf("已上传临时 key=%s", key)
// 2) 删除(期望 200 / nil)
if err := up.Delete(ctx, key); err != nil {
// 401 表示管理 token 签名错误,是本次验证的核心目标
if strings.Contains(err.Error(), "401") {
t.Fatalf("七牛删除返回 401,管理凭证签名错误: %v", err)
}
t.Fatalf("首次删除失败: %v", err)
}
t.Logf("首次删除成功(200),key=%s", key)
// 3) 再删一次(期望 612 资源不存在,证明确实被删除)
if err := up.Delete(ctx, key); err != nil {
if !strings.Contains(err.Error(), "612") {
t.Fatalf("二次删除期望 612(资源不存在),实际: %v", err)
}
t.Logf("二次删除返回 612,确认对象已删除: %s", key)
} else {
t.Fatalf("二次删除应返回 612(资源不存在),却返回 200,说明首次删除未生效")
}
}

View File

@ -0,0 +1,71 @@
// Package storage 抽象「把一段图片字节存成可访问地址」的实现。
//
// 两种实现:
// - LocalUploader:落到本地磁盘,对外以静态路径暴露(兜底/默认实现)。
// - QiniuUploader:直传七牛云对象存储(标准库实现,无第三方 SDK 依赖)。
//
// 入库 worker 主用 Qiniu(若启用),单张失败时兜底写本地,避免图片完全丢失。
package storage
import (
"context"
"os"
"path/filepath"
"strings"
)
// Uploader 抽象图片上传。
// Upload 返回写入数据库的图片访问地址:
// - Qiniu 返回完整 https URL(base_url + key)
// - 本地实现返回 Upload.URLPrefix + "/" + key 的相对路径
type Uploader interface {
// Upload 上传 data 到 key 指定的对象,返回访问 URL。
Upload(ctx context.Context, key string, data []byte, contentType string) (string, error)
// Enabled 表示该实现是否真正启用(用于日志/兜底判断)。
Enabled() bool
}
// Deleter 抽象图片删除(清理孤儿文件用)。
// QiniuUploader / LocalUploader 都实现本接口;仅当某 key 在所有图集/草稿表中
// 引用计数归零时才由调用方触发,避免误删仍被引用的文件。
type Deleter interface {
// Delete 删除 key 指定的对象/文件。key 不存在时按成功处理(幂等)。
Delete(ctx context.Context, key string) error
}
// LocalUploader 把文件落到本地磁盘并对外以静态路径暴露(兜底/默认实现)。
type LocalUploader struct {
Dir string
URLPrefix string
}
// NewLocalUploader 构造本地上传器。
func NewLocalUploader(dir, urlPrefix string) *LocalUploader {
return &LocalUploader{Dir: dir, URLPrefix: strings.TrimRight(urlPrefix, "/")}
}
// Enabled 本地上传始终可用。
func (l *LocalUploader) Enabled() bool { return true }
// Upload 写本地文件并返回相对静态路径(如 /uploads/runway/xxx.jpg)。
func (l *LocalUploader) Upload(ctx context.Context, key string, data []byte, contentType string) (string, error) {
abs := filepath.Join(l.Dir, filepath.FromSlash(key))
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return "", err
}
if err := os.WriteFile(abs, data, 0o644); err != nil {
return "", err
}
return l.URLPrefix + "/" + strings.TrimLeft(key, "/"), nil
}
// Delete 删除本地文件(清理孤儿图用)。key 可能带 URLPrefix 前缀,先剥掉再定位磁盘文件。
func (l *LocalUploader) Delete(ctx context.Context, key string) error {
rel := strings.TrimPrefix(key, l.URLPrefix)
rel = strings.TrimPrefix(rel, "/")
abs := filepath.Join(l.Dir, filepath.FromSlash(rel))
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

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
}
}

View File

@ -0,0 +1,129 @@
package router
import (
"net/http"
"fashionapi/internal/config"
"fashionapi/internal/handler"
"fashionapi/internal/middleware"
"fashionapi/internal/pkg/jwt"
"github.com/gin-gonic/gin"
)
// BackstageOptions 后台引擎所需依赖。
type BackstageOptions struct {
Config *config.Config
JWT *jwt.Manager
Backstage *handler.BackstageHandler
UploadURLPrefix string // 静态资源 URL 前缀(如 /uploads),用于后台渲染封面/图集
UploadDir string // 静态资源本地根目录,与 UploadURLPrefix 配对
// Ingest 爬虫上报 handler(HMAC 验签入口)。为 nil 时不挂载 ingest 路由。
Ingest *handler.IngestHandler
// IngestAuth 验签配置(含 nonce 表)。Secret 为空则 ingest 路由返回 503。
IngestAuth *middleware.IngestAuthConfig
}
// adminCookie 与 handler 包保持一致:后台会话令牌 cookie 名(HttpOnly,路径 /admin)。
const adminCookie = "admin_session"
// NewBackstage 构建管理后台引擎(默认 :8092),返回 *gin.Engine。
//
// 与公开引擎(8090)/ SSG 引擎(8091)的区别:
// 1. 不装载 CORS —— 后台是同源服务端渲染页面,CORS 用不上;
// 2. 登录态走 HttpOnly cookie(admin_session)而非 Bearer,由 requireBackstageLogin 校验;
// 3. 未登录访问 /admin/* 一律 302 跳登录页,无需返回 JSON 401。
//
// 该引擎由 main 监听在独立端口,可独立绑定/限流,与对外服务物理隔离。
func NewBackstage(opt BackstageOptions) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
// 静态资源:让后台能直接渲染封面与图集(与公开引擎共享同一上传目录)。
if opt.UploadURLPrefix != "" && opt.UploadDir != "" {
r.Static(opt.UploadURLPrefix, opt.UploadDir)
}
// 公开:登录页 / 登录动作 / 登出(登出也要先有 cookie,但不需要先校验)
r.GET("/admin/login", opt.Backstage.LoginPage)
r.POST("/admin/login", opt.Backstage.Login)
r.GET("/admin/logout", opt.Backstage.Logout)
// 爬虫上报入口(HMAC 验签,不挂登录 cookie):/admin/internal/ingest
// 仅当配置了 INGEST_SECRET 才挂载真实接口,否则返回 503(尚未启用)。
if opt.Ingest != nil && opt.IngestAuth != nil && opt.IngestAuth.Secret != "" {
internal := r.Group("/admin/internal")
internal.Use(middleware.IngestAuth(*opt.IngestAuth))
internal.POST("/ingest", opt.Ingest.Submit)
internal.GET("/crawl/brands", opt.Ingest.CrawlBrands)
// 图集预检:爬虫抓详情页前批量问「这些 source_url 是否已爬取过」,跳过已存在的。
internal.POST("/crawl/exists", opt.Ingest.CrawlExists)
} else {
r.POST("/admin/internal/ingest", func(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ingest disabled"})
})
}
// 受保护:后台页面,统一走 cookie 鉴权中间件
admin := r.Group("/admin")
admin.Use(requireBackstageLogin(opt.JWT))
{
admin.GET("/", opt.Backstage.Dashboard)
admin.GET("/brands", opt.Backstage.Brands)
admin.GET("/brands/new", opt.Backstage.BrandNew)
admin.POST("/brands", opt.Backstage.BrandCreate)
admin.GET("/brands/:id", opt.Backstage.BrandEdit)
admin.POST("/brands/:id", opt.Backstage.BrandUpdate)
admin.GET("/runways", opt.Backstage.Runways)
admin.GET("/runways/:id", opt.Backstage.RunwayDetail)
admin.GET("/runways/:id/edit", opt.Backstage.RunwayEdit)
admin.POST("/runways/:id/edit", opt.Backstage.RunwayUpdate)
admin.POST("/runways/:id/images/:img/delete", opt.Backstage.RunwayImageDelete)
admin.POST("/runways/:id/cover", opt.Backstage.RunwaySetCover)
admin.POST("/runways/:id/toggle", opt.Backstage.RunwayToggleDeleted)
admin.GET("/street-snaps", opt.Backstage.StreetSnaps)
admin.GET("/street-snaps/:id", opt.Backstage.StreetSnapDetail)
admin.GET("/street-snaps/:id/edit", opt.Backstage.StreetSnapEdit)
admin.POST("/street-snaps/:id/edit", opt.Backstage.StreetSnapUpdate)
admin.POST("/street-snaps/:id/images/:img/delete", opt.Backstage.StreetSnapImageDelete)
admin.POST("/street-snaps/:id/cover", opt.Backstage.StreetSnapSetCover)
admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted)
// 审核草稿:爬虫入库待人工审核 → 通过晋升正式表 / 驳回。
// 路由带 :kind 段(runway / street / 缺省=全部),详情/通过/驳回均按 kind 分流。
admin.GET("/reviews", opt.Backstage.ReviewList)
admin.GET("/reviews/:kind", opt.Backstage.ReviewList)
admin.GET("/reviews/:kind/:id", opt.Backstage.ReviewDetail)
admin.POST("/reviews/:kind/:id/approve", opt.Backstage.ReviewApprove)
admin.POST("/reviews/:kind/:id/reject", opt.Backstage.ReviewReject)
admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewDraftImageDelete)
// 爬虫入库任务监控:列出处理进度 + 失败重试
admin.GET("/ingest-jobs", opt.Backstage.IngestJobs)
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)
// 用户管理:列出用户 + 提级 / 降为 VIP
admin.GET("/users", opt.Backstage.Users)
admin.POST("/users/:id/tier", opt.Backstage.UserSetTier)
}
return r
}
// requireBackstageLogin 校验后台会话 cookie;缺失或失效则跳登录页。
func requireBackstageLogin(jwtManager *jwt.Manager) gin.HandlerFunc {
return func(c *gin.Context) {
token, err := c.Cookie(adminCookie)
if err != nil || token == "" {
c.Redirect(http.StatusFound, "/admin/login")
c.Abort()
return
}
claims, err := jwtManager.Parse(token)
if err != nil {
c.Redirect(http.StatusFound, "/admin/login")
c.Abort()
return
}
c.Set(middleware.ContextUserID, claims.UserID)
c.Set(middleware.ContextUsername, claims.Username)
c.Next()
}
}

View File

@ -0,0 +1,292 @@
package router
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"fashionapi/internal/config"
"fashionapi/internal/dto"
"fashionapi/internal/handler"
"fashionapi/internal/model"
"fashionapi/internal/pkg/jwt"
"fashionapi/internal/service"
)
// ── 测试用假实现(不连库)──────────────────────────────────────────────
type fakeAuth struct{ jwt *jwt.Manager }
func (f *fakeAuth) Me(ctx context.Context, id uint32) (*dto.UserPayload, error) {
return &dto.UserPayload{ID: id, Username: "admin"}, nil
}
func (f *fakeAuth) Login(ctx context.Context, account, password string) (string, string, int, *dto.UserPayload, error) {
if account != "admin" || password != "secret" {
return "", "", 0, nil, errors.New("invalid credentials")
}
tok, _ := f.jwt.Generate(1, "admin", "admin@example.com", "free")
return tok, "refresh", 3600, &dto.UserPayload{ID: 1, Username: "admin", Email: "admin@example.com"}, nil
}
func (f *fakeAuth) Refresh(ctx context.Context, rt string) (string, int, error) {
return "x", 3600, nil
}
func (f *fakeAuth) Logout(ctx context.Context, rt string) error { return nil }
func (f *fakeAuth) RevokeAllByRefresh(ctx context.Context, rt string) (int64, error) {
return 1, nil
}
func (f *fakeAuth) ListUsers(ctx context.Context) ([]dto.UserPayload, error) {
return []dto.UserPayload{{ID: 1, Username: "admin", Tier: "free"}}, nil
}
func (f *fakeAuth) SetTier(ctx context.Context, id uint32, tier string) error { return nil }
type fakeBrand struct{}
func (f *fakeBrand) List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
return []dto.PublicBrand{{UID: "001DESke", NameEn: "Acne Studios", NameCn: "艾克妮"}}, 1, nil
}
func (f *fakeBrand) Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error) {
return nil, nil
}
func (f *fakeBrand) Get(ctx context.Context, uid string) (*model.Brand, error) {
if uid != "001DESke" {
return nil, service.ErrBrandNotFound
}
return &model.Brand{ID: 1, NameEn: "Acne Studios", NameCn: "艾克妮"}, nil
}
func (f *fakeBrand) Create(ctx context.Context, nameEn, nameCn string) (string, error) {
return "009DESke", nil
}
func (f *fakeBrand) Update(ctx context.Context, uid, nameEn, nameCn string) error {
if uid != "001DESke" {
return service.ErrBrandNotFound
}
return nil
}
func (f *fakeBrand) CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error) {
return []dto.CrawlBrand{{BrandUID: "001DESke", Name: "Acne Studios"}}, nil
}
// 编译期接口满足性检查
var (
_ service.ArticleService = (*fakeArticle)(nil)
_ service.StreetSnapService = (*fakeStreet)(nil)
_ service.BrandService = (*fakeBrand)(nil)
)
type fakeArticle struct{}
func (f *fakeArticle) List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error) {
return []dto.PublicArticle{{
UID: "r001DESke",
BrandUID: "001DESke",
Title: "Acne Studios Spring 2024",
Cover: "/uploads/runway/cover.jpg",
BrandName: "Acne Studios",
ImageCount: 12,
}}, 1, nil
}
func (f *fakeArticle) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) {
return []dto.AdminRunway{{
UID: "r001DESke",
BrandUID: "001DESke",
BrandName: "Acne Studios",
Title: "Acne Studios Spring 2024",
Year: 2024,
Season: "spring",
CollectionType: "rtw",
SeasonCode: "SS24",
ImageCount: 12,
SourceURL: "https://www.vogue.com/fashion-shows",
}}, 1, nil
}
func (f *fakeArticle) Detail(ctx context.Context, id, locale string, hd bool) (*dto.PublicArticleDetail, error) {
return &dto.PublicArticleDetail{
UID: id,
BrandUID: "001DESke",
Title: "Acne Studios Spring 2024",
BrandName: "Acne Studios",
SourceURL: "https://www.vogue.com/fashion-shows",
Images: []dto.PublicArticleImage{
{ID: "i1", Image: "/uploads/runway/1.jpg", Name: "Look 1"},
{ID: "i2", Image: "/uploads/runway/2.jpg", Name: "Look 2"},
},
}, nil
}
func (f *fakeArticle) GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) {
return &model.BrandRunway{ID: id, TitleEn: "Acne Studios Spring 2024"}, nil, nil
}
func (f *fakeArticle) UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error {
return nil
}
func (f *fakeArticle) DeleteImage(ctx context.Context, imageID uint32) error { return nil }
func (f *fakeArticle) SetCover(ctx context.Context, runwayID uint32, image string) error {
return nil
}
func (f *fakeArticle) SetDeleted(ctx context.Context, id uint32, deleted uint8) error {
return nil
}
func (f *fakeArticle) PurgeGallery(ctx context.Context, id uint32) error {
return nil
}
type fakeStreet struct{}
func (f *fakeStreet) List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) {
return []dto.PublicStreetSnap{{
UID: "s001DESke",
Title: "Paris Fashion Week Street",
Cover: "/uploads/snap/cover.jpg",
ImageCount: 8,
}}, 1, nil
}
func (f *fakeStreet) Detail(ctx context.Context, id string, hd bool) (*dto.PublicStreetSnapDetail, error) {
return &dto.PublicStreetSnapDetail{
UID: id,
Title: "Paris Fashion Week Street",
Images: []dto.PublicArticleImage{
{ID: "j1", Image: "/uploads/snap/1.jpg", Name: "Snap 1"},
},
}, nil
}
func (f *fakeStreet) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {
return nil, nil
}
func (f *fakeStreet) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
return &model.StreetSnap{ID: id, Title: "Paris Fashion Week Street"}, nil, nil
}
func (f *fakeStreet) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {
return nil
}
func (f *fakeStreet) DeleteImage(ctx context.Context, imageID uint32) error { return nil }
func (f *fakeStreet) SetCover(ctx context.Context, snapID uint32, image string) error {
return nil
}
func (f *fakeStreet) SetDeleted(ctx context.Context, id uint32, deleted uint8) error {
return nil
}
func (f *fakeStreet) PurgeGallery(ctx context.Context, id uint32) error {
return nil
}
// ── 冒烟测试:验证路由 / 鉴权 / 模板渲染(无需真实数据库)─────────────────
func TestBackstage(t *testing.T) {
m := jwt.NewManager("test-secret", 168)
auth := &fakeAuth{jwt: m}
brand := &fakeBrand{}
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, nil, nil)
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
// 1) 登录页可访问
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
if w.Code != 200 || !strings.Contains(w.Body.String(), "管理后台登录") {
t.Fatalf("登录页异常: %d %s", w.Code, w.Body.String())
}
// 2) 未登录访问 /admin/ 应 302 跳登录
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/admin/", nil))
if w.Code != 302 || !strings.Contains(w.Header().Get("Location"), "/admin/login") {
t.Fatalf("未登录保护异常: %d %s", w.Code, w.Header().Get("Location"))
}
// 3) 错误密码不种 cookie
w = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader("account=admin&password=wrong"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.ServeHTTP(w, req)
if w.Code != 200 || len(w.Result().Cookies()) != 0 {
t.Fatalf("错误密码应停留登录页且不种 cookie: %d cookies=%d", w.Code, len(w.Result().Cookies()))
}
// 4) 正确登录种 cookie 并跳首页
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader("account=admin&password=secret"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.ServeHTTP(w, req)
if w.Code != 302 {
t.Fatalf("登录应 302: %d", w.Code)
}
cookies := w.Result().Cookies()
if len(cookies) == 0 || cookies[0].Name != adminCookie || cookies[0].Value == "" {
t.Fatalf("登录未下发会话 cookie")
}
token := cookies[0].Value
// 5) 带 cookie 访问品牌列表,应渲染出数据
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/brands", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Acne Studios") {
t.Fatalf("品牌列表异常: %d %s", w.Code, w.Body.String())
}
// 5b) 品牌新建表单可访问
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/brands/new", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "新建品牌") {
t.Fatalf("品牌新建表单异常: %d %s", w.Code, w.Body.String())
}
// 5c) 品牌编辑表单渲染现有值
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/brands/001DESke", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Acne Studios") {
t.Fatalf("品牌编辑表单异常: %d %s", w.Code, w.Body.String())
}
// 5d) 提交新建应 302 跳回列表
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/admin/brands", strings.NewReader("name_en=Test+Brand&name_cn=%E8%AF%95"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 302 || w.Header().Get("Location") != "/admin/brands" {
t.Fatalf("品牌新建提交异常: %d %s", w.Code, w.Header().Get("Location"))
}
// 6) 走秀列表渲染(含后台管理字段:季节码 SS24 / 来源)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/runways", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Acne Studios Spring 2024") || !strings.Contains(w.Body.String(), "SS24") {
t.Fatalf("走秀列表异常: %d %s", w.Code, w.Body.String())
}
// 7) 走秀详情渲染图集
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/runways/r001DESke", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Look 1") {
t.Fatalf("走秀详情异常: %d %s", w.Code, w.Body.String())
}
// 8) 街拍列表渲染
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Paris Fashion Week Street") {
t.Fatalf("街拍列表异常: %d %s", w.Code, w.Body.String())
}
// 9) 街拍详情渲染图集
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps/s001DESke", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Snap 1") {
t.Fatalf("街拍详情异常: %d %s", w.Code, w.Body.String())
}
}

View File

@ -0,0 +1,53 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"fashionapi/internal/pkg/storage"
)
// TestFetchImagesCleansUpOnFailure 验证「单图失败=整任务失败」时,本批已成功上传的对象被回滚删除,
// 不会在存储里留下孤儿文件。
func TestFetchImagesCleansUpOnFailure(t *testing.T) {
dir := t.TempDir()
local := storage.NewLocalUploader(dir, "/uploads")
s := &IngestService{uploader: local, local: local, httpClient: &http.Client{}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "bad") {
w.WriteHeader(500)
return
}
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("fakeimagebytes"))
}))
defer srv.Close()
urls := []string{srv.URL + "/ok1.jpg", srv.URL + "/ok2.jpg", srv.URL + "/bad.jpg"}
_, out, keys, failed := s.fetchImages(context.Background(), urls, "runway")
if !failed {
t.Fatalf("expected failed=true when one image errors")
}
if len(out) != 2 || len(keys) != 2 {
t.Fatalf("expected 2 successful uploads, got out=%d keys=%d", len(out), len(keys))
}
for _, k := range keys {
if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(k))); err != nil {
t.Fatalf("uploaded file missing before cleanup: %s", k)
}
}
s.cleanupUploads(context.Background(), keys)
for _, k := range keys {
if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(k))); !os.IsNotExist(err) {
t.Fatalf("orphan file not cleaned up: %s", k)
}
}
}

View File

@ -0,0 +1,425 @@
package service
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"path"
"time"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/season"
"fashionapi/internal/pkg/storage"
"fashionapi/internal/repository"
)
// IngestService 爬虫入库管线的业务逻辑层:Submit 入队,worker 异步处理。
//
// 队列复用一张 ingest_jobs 表,靠 kind 列分流两类任务:
// - crawl:爬虫上报的走秀/街拍入库(下载图 → 写草稿)
// - media_cleanup:删除图集时异步清理七牛孤儿图(引用计数归零才真删)
type IngestService struct {
repo repository.IngestRepository
brandRepo repository.BrandRepository
media repository.MediaRepository // 跨表图片引用计数(media_cleanup 删孤儿用)
uploader storage.Uploader // 主上传器(七牛启用时为七牛,否则本地)
del storage.Deleter // 删除器(七牛或本地,media_cleanup 真删用)
local *storage.LocalUploader // 七牛失败时的兜底落地
httpClient *http.Client
}
// NewIngestService 创建入库服务。
//
// uploader: 主上传器(七牛或本地)
// del: 删除器(七牛或本地),用于 media_cleanup 真删七牛/本地孤儿文件
// local: 本地兜底上传器(七牛上传失败时回退,避免图片完全丢失)
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader) *IngestService {
return &IngestService{
repo: repo,
brandRepo: brandRepo,
media: media,
uploader: uploader,
del: del,
local: local,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
// Submit 把一条上报写入队列(payload 原样存 JSON),立即返回任务 id。
// 真正处理由 worker 异步完成,因此本方法本身很快、不阻塞爬虫。
func (s *IngestService) Submit(ctx context.Context, payload dto.RunwayIngest) (uint32, error) {
if payload.Kind == "" {
payload.Kind = dto.IngestKindRunway
}
raw, err := json.Marshal(payload)
if err != nil {
return 0, NewError(http.StatusBadRequest, "payload invalid: "+err.Error())
}
if payload.SourceURL == "" {
return 0, NewError(http.StatusBadRequest, "source_url required")
}
// runway 必须带品牌;street 不关联品牌,brand_uid 可选
if payload.Kind == dto.IngestKindRunway && payload.BrandUID == "" {
return 0, NewError(http.StatusBadRequest, "brand_uid required")
}
job := &model.IngestJob{SourceURL: payload.SourceURL, Payload: string(raw), Kind: model.IngestKindCrawl}
if err := s.repo.Enqueue(ctx, job); err != nil {
return 0, internalErr(err.Error())
}
return job.ID, nil
}
// ExistsSourceURLs 批量判断 source_url 是否已爬取过(爬虫抓取前的预检)。
//
// 返回命中集合:key 为已存在的 source_url,value 恒为 true。爬虫拿到结果后应跳过这些图集,
// 不必再抓详情页、提取图片 URL 并上报——这一整套动作在判重命中时本来也会被 worker 丢弃。
// 判定条件与 worker 判重完全一致(正式表未删除 / 草稿表 pending),因此不会漏抓也不会重复抓。
func (s *IngestService) ExistsSourceURLs(ctx context.Context, urls []string) (map[string]bool, error) {
return s.repo.ExistsSourceURLs(ctx, urls)
}
// ListJobs 列出最近的入库任务(后台监控页用)。
func (s *IngestService) ListJobs(ctx context.Context, limit int) ([]model.IngestJob, error) {
return s.repo.ListJobs(ctx, limit)
}
// RetryJob 把一条 failed 任务重新入队,等待 worker 重新处理。
func (s *IngestService) RetryJob(ctx context.Context, id uint32) error {
return s.repo.RetryJob(ctx, id)
}
// Run 启动 worker 循环:定时领取 pending 任务并处理,直到 ctx 取消。
// 多实例安全:Claim 用 SKIP LOCKED,互不抢同一条。
func (s *IngestService) Run(ctx context.Context, batch int, interval time.Duration) {
if batch < 1 {
batch = 2
}
if interval <= 0 {
interval = 3 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.drain(ctx, batch)
}
}
}
// drain 一次性领取并处理一批任务。
func (s *IngestService) drain(ctx context.Context, batch int) {
jobs, err := s.repo.Claim(ctx, batch)
if err != nil {
return
}
for i := range jobs {
s.process(ctx, jobs[i])
}
}
// process 处理单条任务:先按 job.Kind(DB 列)分流到 crawl / media_cleanup 两条管线。
// 旧爬虫任务(迁移前)kind 为空,回落 crawl 走秀/街拍入库管线。
func (s *IngestService) process(ctx context.Context, job model.IngestJob) {
switch job.Kind {
case model.IngestKindMediaCleanup:
s.processMediaCleanup(ctx, job)
return
case "", model.IngestKindCrawl:
// 走秀/街拍入库管线,按 payload 内 Kind 再分流 runway / street。
default:
// 未知 job.Kind 绝不静默处理,标记失败避免脏数据。
_ = s.repo.MarkFailed(ctx, job.ID, "unknown job kind: "+job.Kind)
return
}
var p dto.RunwayIngest
if err := json.Unmarshal([]byte(job.Payload), &p); err != nil {
_ = s.repo.MarkFailed(ctx, job.ID, "payload parse: "+err.Error())
return
}
if p.Kind == "" {
p.Kind = dto.IngestKindRunway
}
switch p.Kind {
case dto.IngestKindRunway:
s.processRunway(ctx, job, p)
case dto.IngestKindStreet:
s.processStreet(ctx, job, p)
default:
// 未知 kind 绝不静默当成走秀处理;所有爬虫数据都必须落到已注册的审核模块,
// 否则标记任务失败,避免出现「未审核就入库」的脏数据。
_ = s.repo.MarkFailed(ctx, job.ID, "unknown ingest kind: "+p.Kind)
}
}
// MediaCleanupPayload 是「清理七牛孤儿图」任务的 payload:待清理的图片 key 列表。
type MediaCleanupPayload struct {
Keys []string `json:"keys"`
}
// EnqueueMediaCleanup 把一批待清理的七牛 key 异步入队;真正删除由 worker 的
// processMediaCleanup 按引用计数判定,仅当 key 在所有图集/草稿表中引用归零才真删
// (内容寻址共享 key 不会被误删)。
func (s *IngestService) EnqueueMediaCleanup(ctx context.Context, keys []string) error {
if len(keys) == 0 {
return nil
}
raw, err := json.Marshal(MediaCleanupPayload{Keys: keys})
if err != nil {
return err
}
return s.repo.EnqueueMediaCleanup(ctx, string(raw))
}
// processMediaCleanup 清理七牛孤儿图任务:解析 key 列表,按跨表引用计数删除真孤儿。
// 删除/计数失败一律保守跳过(宁可留文件),因此本任务几乎总是成功置 done。
func (s *IngestService) processMediaCleanup(ctx context.Context, job model.IngestJob) {
if s.media == nil || s.del == nil {
_ = s.repo.MarkFailed(ctx, job.ID, "media cleanup not configured")
return
}
var p MediaCleanupPayload
if err := json.Unmarshal([]byte(job.Payload), &p); err != nil {
_ = s.repo.MarkFailed(ctx, job.ID, "media cleanup payload parse: "+err.Error())
return
}
if len(p.Keys) == 0 {
_ = s.repo.MarkDone(ctx, job.ID)
return
}
purgeOrphanImages(ctx, s.media, s.del, p.Keys)
_ = s.repo.MarkDone(ctx, job.ID)
}
// failOrRetry 把临时失败(如网络抖动 / 单图下载失败)按指数退避自动重试:
// attempts 未达上限则重置为 pending 并推迟 next_attempt_at,worker 到点再领;
// 达上限才置 failed,等待后台手动「重试」。永久失败(payload 解析错 / 未知 kind)直接
// MarkFailed,不进重试。
func (s *IngestService) failOrRetry(ctx context.Context, id uint32, errMsg string) {
if err := s.repo.ScheduleRetry(ctx, id, errMsg); err != nil {
// 调度失败兜底硬失败,避免任务卡在 processing 无人收。
_ = s.repo.MarkFailed(ctx, id, errMsg)
}
}
// processRunway 走秀入库:品牌校验 → 去重 → 补季节码 → 下载图 → 写 brand_runway_draft。
func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
// 1) 品牌必须存在(爬虫负责先建/复用品牌)
brandID, err := hashid.Decode(p.BrandUID)
if err != nil {
_ = s.repo.MarkFailed(ctx, job.ID, "unknown brand_uid")
return
}
if _, err := s.brandRepo.FindByID(ctx, brandID); err != nil {
_ = s.repo.MarkFailed(ctx, job.ID, "brand not found")
return
}
// 2) 按 source_url 去重(幂等):每来源一条草稿。多来源爬同品牌同季时各建一条草稿,
// 晋升阶段(SaveRunwayFromDraft)按实体键(品牌+季节码+系列)合并为正式表一条并聚合图片。
if _, found, err := s.repo.RunwayIDBySourceURL(ctx, p.SourceURL); err == nil && found {
_ = s.repo.MarkDone(ctx, job.ID)
return
}
if _, found, err := s.repo.DraftIDBySourceURL(ctx, p.SourceURL); err == nil && found {
_ = s.repo.MarkDone(ctx, job.ID)
return
}
// 3) 补 season_code(vogue.go 历史漏填的 bug,统一在此兜底)
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
// 4) 下载图片并上传到存储(七牛优先,失败兜底本地)
cover, imgs, keys, imgFailed := s.fetchImages(ctx, p.Images, "runway")
if len(p.Images) > 0 && imgFailed {
// 单图失败=整任务失败:先回滚本批已上传的图(七牛 + 本地兜底),避免孤儿文件永远堆在存储里,
// 然后按指数退避自动重试,达上限才置 failed 等后台手动重试。
s.cleanupUploads(ctx, keys)
s.failOrRetry(ctx, job.ID, "image download failed")
return
}
// 5) 写草稿表(status=pending),等待后台审核通过后再晋升正式表
draft := &model.BrandRunwayDraft{
JobID: job.ID,
BrandID: brandID,
TitleEn: p.TitleEn,
TitleCn: p.TitleCn,
DescriptionEn: p.DescriptionEn,
DescriptionCn: p.DescriptionCn,
Year: p.Year,
Season: p.Season,
CollectionType: p.CollectionType,
SeasonCode: seasonCode,
Cover: cover,
SourceURL: p.SourceURL,
ImageCount: uint16(len(imgs)),
Status: model.DraftStatusPending,
}
id, err := s.repo.CreateRunwayDraft(ctx, draft)
if err != nil {
s.failOrRetry(ctx, job.ID, "create draft: "+err.Error())
return
}
rows := make([]model.BrandRunwayDraftImage, 0, len(imgs))
for i, img := range imgs {
rows = append(rows, model.BrandRunwayDraftImage{
DraftID: id,
Image: img,
Name: fmt.Sprintf("Look %d", i+1),
SortOrder: uint32(i + 1),
})
}
if err := s.repo.CreateRunwayDraftImages(ctx, rows); err != nil {
s.failOrRetry(ctx, job.ID, "create draft images: "+err.Error())
return
}
_ = s.repo.MarkDone(ctx, job.ID)
}
// processStreet 街拍入库:去重 → 下载图 → 写 street_snap_draft(无品牌)。
func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
// 1) 按 source_url 去重(幂等):每来源一条草稿。多来源爬同城市同年时各建一条草稿,
// 晋升阶段(SaveStreetSnapFromDraft)按实体键(城市+年份)合并为正式表一条并聚合图片。
if _, found, err := s.repo.StreetSnapIDBySourceURL(ctx, p.SourceURL); err == nil && found {
_ = s.repo.MarkDone(ctx, job.ID)
return
}
if _, found, err := s.repo.DraftStreetIDBySourceURL(ctx, p.SourceURL); err == nil && found {
_ = s.repo.MarkDone(ctx, job.ID)
return
}
// 2) 下载图片并上传到存储(七牛优先,失败兜底本地)
cover, imgs, keys, imgFailed := s.fetchImages(ctx, p.Images, "street")
if len(p.Images) > 0 && imgFailed {
// 单图失败=整任务失败:先回滚本批已上传的图(七牛 + 本地兜底),避免孤儿文件永远堆在存储里,
// 然后按指数退避自动重试,达上限才置 failed 等后台手动重试。
s.cleanupUploads(ctx, keys)
s.failOrRetry(ctx, job.ID, "image download failed")
return
}
// 3) 写草稿表(status=pending),等待后台审核通过后再晋升 street_snap 正式表
draft := &model.StreetSnapDraft{
JobID: job.ID,
Title: p.TitleEn, // 街拍单标题,爬虫优先填 title_en
Year: p.Year,
City: p.City,
Cover: cover,
SourceURL: p.SourceURL,
ImageCount: uint16(len(imgs)),
Status: model.DraftStatusPending,
}
id, err := s.repo.CreateStreetSnapDraft(ctx, draft)
if err != nil {
s.failOrRetry(ctx, job.ID, "create street draft: "+err.Error())
return
}
rows := make([]model.StreetSnapDraftImage, 0, len(imgs))
for i, img := range imgs {
rows = append(rows, model.StreetSnapDraftImage{
DraftID: id,
Image: img,
Name: fmt.Sprintf("Look %d", i+1),
SortOrder: uint32(i + 1),
})
}
if err := s.repo.CreateStreetSnapDraftImages(ctx, rows); err != nil {
s.failOrRetry(ctx, job.ID, "create street draft images: "+err.Error())
return
}
_ = s.repo.MarkDone(ctx, job.ID)
}
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 全部图片地址, 已成功上传对象的 key 列表, 是否有任意一张失败)。
// prefix 为七牛 key 前缀(runway/ 或 street/)。只要任意一张下载/上传失败,failed 即置 true,
// 调用方据此把整条任务判为失败(不再写草稿),并拿 keys 回滚本批已上传的对象,符合「单图失败=整任务失败」策略。
func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix string) (string, []string, []string, bool) {
cover := ""
out := make([]string, 0, len(urls))
keys := make([]string, 0, len(urls))
failed := false
for i, u := range urls {
url, key, err := s.downloadOne(ctx, u, i, prefix)
if err != nil {
failed = true
continue
}
if i == 0 {
cover = url
}
out = append(out, url)
keys = append(keys, key)
}
return cover, out, keys, failed
}
// cleanupUploads 删除一批本批次成功上传的对象(七牛 + 本地兜底),用于任务失败回滚:
// 「单图失败=整任务失败」时,前面已成功上传的图若放任不管就成了孤儿,永远堆在存储里。
// 对两种存储都尝试删除(任一不存在即按幂等成功处理);删除失败仅告警,不阻断任务置失败。
// 注意:key 为 sha1 内容寻址,若恰与其他已晋升图集共享同一内容哈希会被一并移除,重试会重新上传补齐。
func (s *IngestService) cleanupUploads(ctx context.Context, keys []string) {
for _, k := range keys {
if d, ok := s.uploader.(storage.Deleter); ok {
if err := d.Delete(ctx, k); err != nil {
log.Printf("[warn] ingest cleanup: 七牛删除失败 key=%s err=%v", k, err)
}
}
if s.local != nil {
if err := s.local.Delete(ctx, k); err != nil {
log.Printf("[warn] ingest cleanup: 本地删除失败 key=%s err=%v", k, err)
}
}
}
}
// downloadOne 把单张远程图下载后上传到存储(七牛优先,失败兜底本地),
// 返回可直接写入数据库的访问地址(七牛为完整 https URL,本地为相对 /uploads 路径)。
func (s *IngestService) downloadOne(ctx context.Context, u string, idx int, prefix string) (string, string, error) {
resp, err := s.httpClient.Get(u)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("status %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", err
}
ext := path.Ext(u)
if ext == "" || len(ext) > 5 {
ext = ".jpg"
}
// 内容哈希作 key:同一张图(无论来自哪篇文章/job)永远得到相同 key,
// 七牛覆盖写即天然幂等,不会因重复采集、崩溃重试、reject 重爬而累积孤儿文件。
h := sha1.Sum(data)
hash := hex.EncodeToString(h[:])
key := fmt.Sprintf("%s/%s%s", prefix, hash, ext)
ct := resp.Header.Get("Content-Type")
// 主上传器(七牛)
if url, err := s.uploader.Upload(ctx, key, data, ct); err == nil {
return url, key, nil
} else if s.local != nil {
// 兜底本地,避免图片完全丢失
if lurl, lerr := s.local.Upload(ctx, key, data, ct); lerr == nil {
return lurl, key, nil
} else {
return "", "", err
}
}
return "", "", err
}

View File

@ -0,0 +1,86 @@
package service
import (
"context"
"testing"
"fashionapi/internal/model"
"fashionapi/internal/pkg/storage"
"fashionapi/internal/repository"
)
// fakeMediaRepo 计数固定返回 0:即所有 key 都是孤儿,应被真删(不误删的前提是调用方已软删引用)。
type fakeMediaRepo struct{ repository.MediaRepository }
func (fakeMediaRepo) CountImageKeyRefs(ctx context.Context, key string) (int, error) { return 0, nil }
// fakeIngestRepo 仅实现 processMediaCleanup 需要的 MarkDone/MarkFailed(其余由内嵌 nil 接口兜底,不会被调用)。
type fakeIngestRepo struct{ repository.IngestRepository }
func (fakeIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil }
func (fakeIngestRepo) MarkFailed(ctx context.Context, id uint32, errMsg string) error {
return nil
}
// countingDeleter 包装 LocalUploader,记录被删除的 key,便于断言。
type countingDeleter struct {
*storage.LocalUploader
deleted []string
}
func (c *countingDeleter) Delete(ctx context.Context, key string) error {
c.deleted = append(c.deleted, key)
return c.LocalUploader.Delete(ctx, key)
}
// TestProcessMediaCleanup 验证「清理七牛孤儿图」任务:引用计数为 0 的 key 会被真删。
func TestProcessMediaCleanup(t *testing.T) {
dir := t.TempDir()
up := storage.NewLocalUploader(dir, "/uploads")
del := &countingDeleter{LocalUploader: up}
// 预置一个"孤儿"文件,确认它确实存在。
if _, err := up.Upload(context.Background(), "runway/abc.jpg", []byte("x"), "image/jpeg"); err != nil {
t.Fatalf("预置文件失败: %v", err)
}
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up)
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/abc.jpg"]}`}
svc.processMediaCleanup(context.Background(), job)
if len(del.deleted) != 1 || del.deleted[0] != "runway/abc.jpg" {
t.Fatalf("期望删除 1 个孤儿 key,实际 %v", del.deleted)
}
}
// TestProcessMediaCleanupSkipsReferenced 引用计数 > 0 的 key 必须保留(不误删共享 key)。
func TestProcessMediaCleanupSkipsReferenced(t *testing.T) {
dir := t.TempDir()
up := storage.NewLocalUploader(dir, "/uploads")
del := &countingDeleter{LocalUploader: up}
// 引用计数恒为 1(仍被其他图集使用),不应真删。
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up)
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/shared.jpg"]}`}
svc.processMediaCleanup(context.Background(), job)
if len(del.deleted) != 0 {
t.Fatalf("被引用的 key 不应被删除,实际 %v", del.deleted)
}
}
type referencedMediaRepo struct{ repository.MediaRepository }
func (referencedMediaRepo) CountImageKeyRefs(ctx context.Context, key string) (int, error) {
return 1, nil
}
// TestIngestRetryBackoff 验证指数退避:30s / 60s / 120s 且封顶 600s。
func TestIngestRetryBackoff(t *testing.T) {
cases := map[int]int{1: 30, 2: 60, 3: 120, 10: 600}
for attempts, want := range cases {
if got := model.IngestRetryBackoff(attempts); got != want {
t.Fatalf("IngestRetryBackoff(%d)=%d, want %d", attempts, got, want)
}
}
}

View File

@ -0,0 +1,45 @@
package service
import (
"context"
"log"
"fashionapi/internal/pkg/storage"
"fashionapi/internal/repository"
)
// purgeOrphanImages 清理七牛/本地孤儿图片:仅当某 key 在所有图集/草稿表中
// 引用计数归零时才真正删除对象;否则保留(仍有其他图集在用,避免误删)。
//
// 设计要点(为何必须引用计数而非「删库即删七牛」):
// 七牛 key 为 sha1 内容寻址,同一张图跨图集共享同一 key。直接删会误伤其他图集。
// 另外,删除失败/计数失败一律「保守跳过」——宁可留文件,不冒误删被引用文件的风险。
//
// 参数 del 为 storage.Deleter(七牛或本地实现),由装配点按配置注入。
func purgeOrphanImages(ctx context.Context, media repository.MediaRepository, del storage.Deleter, keys []string) {
seen := make(map[string]struct{}, len(keys))
for _, k := range keys {
if k == "" {
continue
}
if _, ok := seen[k]; ok {
continue // 同一次批量去重,避免重复删
}
seen[k] = struct{}{}
n, err := media.CountImageKeyRefs(ctx, k)
if err != nil {
log.Printf("[purge] 引用计数失败,跳过 key=%s: %v", k, err)
continue
}
if n > 0 {
// 仍被引用(含其他图集/草稿),保留。
continue
}
if err := del.Delete(ctx, k); err != nil {
log.Printf("[purge] 七牛/本地删除失败,保留 key=%s: %v", k, err)
continue
}
log.Printf("[purge] 已清理孤儿图片 key=%s", k)
}
}

View File

@ -0,0 +1,491 @@
package service
import (
"context"
"sort"
"strconv"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/imgurl"
"fashionapi/internal/pkg/storage"
"fashionapi/internal/repository"
)
// DraftCard 审核列表统一卡片(所有模块共用),供后台列表页渲染。
type DraftCard struct {
Kind string `json:"kind"` // runway | street | 未来其它模块
ID uint32 `json:"id"`
Title string `json:"title"` // 展示标题
Subtitle string `json:"subtitle"` // 副标题(runway=季节码·类型,street=城市)
Cover string `json:"cover"`
ImageCount uint16 `json:"image_count"`
Status string `json:"status"`
SourceURL string `json:"source_url"`
CreatedAt uint32 `json:"created_at"`
UpdatedAt uint32 `json:"updated_at"`
}
// DraftImageRef 草稿图片引用(统一结构)。
type DraftImageRef struct {
ID uint32 `json:"id"`
Image string `json:"image"`
Name string `json:"name"`
SortOrder uint32 `json:"sort_order"`
}
// EditField 审核详情页里可编辑字段的通用描述;详情页按此循环渲染输入框,
// 新增模块只需声明自己的字段集合,无需再改模板分支。
type EditField struct {
Name string `json:"name"` // 表单字段名,如 title_en
Label string `json:"label"` // 中文标签,如 英文标题
Value string `json:"value"` // 当前值
Type string `json:"type"` // text | textarea | number
}
// DraftDetailView 审核详情统一视图(所有模块共用)。
type DraftDetailView struct {
Kind string `json:"kind"`
ID uint32 `json:"id"`
BrandID uint32 `json:"brand_id"` // 仅 runway 等有品牌的模块有,其余为 0
Title string `json:"title"` // 展示标题(runway=title_en,street=title)
TitleEn string `json:"title_en"`
TitleCn string `json:"title_cn"`
DescriptionEn string `json:"description_en"`
DescriptionCn string `json:"description_cn"`
Year uint16 `json:"year"`
Season string `json:"season"`
CollectionType string `json:"collection_type"`
SeasonCode string `json:"season_code"`
City string `json:"city"`
Cover string `json:"cover"`
SourceURL string `json:"source_url"`
ImageCount uint16 `json:"image_count"`
Status string `json:"status"`
Images []DraftImageRef `json:"images"`
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
Fields []EditField `json:"fields"`
}
// KindTab 审核列表页顶部「类型」标签;Pending 为该模块待审数(驱动大审核列表概览)。
type KindTab struct {
Kind string `json:"kind"` // 空串表示「全部」
Label string `json:"label"`
Pending int64 `json:"pending"`
}
// ReviewModule 一种待审草稿模块(runway / street / 未来任意爬虫数据类型)的统一契约。
//
// 新增一种爬虫数据类型需要审核时,只需:
// 1. 建草稿表 + 迁移;
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/SaveFromDraft/CountPending;
// 3. 在此文件实现本接口并注册到 NewReviewService。
//
// 列表、详情、通过、驳回、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
type ReviewModule interface {
Kind() string
Label() string
ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error)
DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error)
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error
// Approve 晋升到正式表并把草稿置 approved;返回正式表主键。
Approve(ctx context.Context, id uint32, reviewer string) (uint32, error)
Reject(ctx context.Context, id uint32, reviewer, reason string) error
CountPending(ctx context.Context) (int64, error)
}
// ReviewService 审核草稿的业务逻辑:列出 / 查看 / 微调 / 通过(晋升)/ 驳回。
// 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。
type ReviewService interface {
ListDrafts(ctx context.Context, kind, status string, page, size int) ([]DraftCard, int64, error)
DraftDetail(ctx context.Context, kind string, id uint32) (*DraftDetailView, error)
UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error
Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error)
Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error
CountPending(ctx context.Context) (int64, error)
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删七牛文件)。
DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
// KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。
KindTabs(ctx context.Context) []KindTab
}
type reviewService struct {
repo repository.ReviewRepository
media repository.MediaRepository
img *imgurl.Composer
deleter storage.Deleter
modules map[string]ReviewModule
order []string // 保持稳定顺序:注册顺序
}
// NewReviewService 创建审核服务并注册所有已知模块。
// img 负责把库里存储的七牛 key 拼装为高清 URL(后台审核预览始终看高清原图);
// media + deleter 用于审核阶段剔除坏图时清理七牛孤儿文件(引用计数归零才真删)。
func NewReviewService(repo repository.ReviewRepository, media repository.MediaRepository, img *imgurl.Composer, deleter storage.Deleter) ReviewService {
svc := &reviewService{
repo: repo,
media: media,
img: img,
deleter: deleter,
modules: map[string]ReviewModule{},
}
for _, m := range []ReviewModule{
&runwayModule{repo: repo, img: img},
&streetModule{repo: repo, img: img},
} {
svc.modules[m.Kind()] = m
svc.order = append(svc.order, m.Kind())
}
return svc
}
func (s *reviewService) module(kind string) (ReviewModule, bool) {
m, ok := s.modules[kind]
return m, ok
}
func (s *reviewService) ListDrafts(ctx context.Context, kind, status string, page, size int) ([]DraftCard, int64, error) {
if kind == "all" {
kind = ""
}
if kind != "" {
m, ok := s.module(kind)
if !ok {
return []DraftCard{}, 0, nil
}
return m.ListDrafts(ctx, status, page, size)
}
// 合并所有模块:各取较多行后在内存按创建时间倒序,再分页(后台量小,足够准确)。
var all []DraftCard
var total int64
for _, k := range s.order {
cards, t, err := s.modules[k].ListDrafts(ctx, status, 1, 1000)
if err != nil {
return nil, 0, err
}
all = append(all, cards...)
total += t
}
sort.Slice(all, func(i, j int) bool { return all[i].CreatedAt > all[j].CreatedAt })
if size < 1 || size > 100 {
size = 20
}
if page < 1 {
page = 1
}
start := (page - 1) * size
if start < 0 || start > len(all) {
start = 0
}
end := start + size
if end > len(all) {
end = len(all)
}
if start > end {
start = end
}
return all[start:end], total, nil
}
func (s *reviewService) DraftDetail(ctx context.Context, kind string, id uint32) (*DraftDetailView, error) {
m, ok := s.module(kind)
if !ok {
return nil, repository.ErrNotFound
}
return m.DraftDetail(ctx, id)
}
func (s *reviewService) UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error {
m, ok := s.module(kind)
if !ok {
return repository.ErrNotFound
}
return m.UpdateDraft(ctx, id, fields)
}
func (s *reviewService) Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error) {
m, ok := s.module(kind)
if !ok {
return 0, repository.ErrNotFound
}
return m.Approve(ctx, id, reviewer)
}
func (s *reviewService) Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error {
m, ok := s.module(kind)
if !ok {
return repository.ErrNotFound
}
return m.Reject(ctx, id, reviewer, reason)
}
func (s *reviewService) CountPending(ctx context.Context) (int64, error) {
var total int64
for _, k := range s.order {
n, err := s.modules[k].CountPending(ctx)
if err != nil {
return 0, err
}
total += n
}
return total, nil
}
func (s *reviewService) KindTabs(ctx context.Context) []KindTab {
tabs := []KindTab{{Kind: "", Label: "全部"}}
var all int64
for _, k := range s.order {
m := s.modules[k]
n, err := m.CountPending(ctx)
if err != nil {
n = 0
}
tabs = append(tabs, KindTab{Kind: k, Label: m.Label(), Pending: n})
all += n
}
tabs[0].Pending = all
return tabs
}
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图)。
// 按 kind 委派到对应草稿图片表的软删,并同步草稿 image_count;
// 若该 key 在全库已无其他引用(引用计数归零),则一并清理七牛孤儿文件。
func (s *reviewService) DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error {
if _, ok := s.module(kind); !ok {
return repository.ErrNotFound
}
key, err := s.repo.GetDraftImageKey(ctx, kind, draftID, imgID)
if err != nil {
return err
}
switch kind {
case dto.IngestKindRunway:
err = s.repo.SoftDeleteRunwayDraftImage(ctx, draftID, imgID)
case dto.IngestKindStreet:
err = s.repo.SoftDeleteStreetDraftImage(ctx, draftID, imgID)
default:
return repository.ErrNotFound
}
if err != nil {
return err
}
if key != "" {
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
}
return nil
}
// ── runway 模块 ────────────────────────────────────────────────────────────────
type runwayModule struct {
repo repository.ReviewRepository
img *imgurl.Composer
}
func (m *runwayModule) Kind() string { return dto.IngestKindRunway }
func (m *runwayModule) Label() string { return "走秀" }
func (m *runwayModule) ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) {
rows, total, err := m.repo.ListDrafts(ctx, status, page, size)
if err != nil {
return nil, 0, err
}
return m.toRunwayCards(rows), total, nil
}
func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) {
d, err := m.repo.DraftDetail(ctx, id)
if err != nil {
return nil, err
}
imgs, err := m.repo.DraftImages(ctx, id)
if err != nil {
return nil, err
}
v := &DraftDetailView{
Kind: dto.IngestKindRunway,
ID: d.ID,
BrandID: d.BrandID,
Title: d.TitleEn,
TitleEn: d.TitleEn,
TitleCn: d.TitleCn,
DescriptionEn: d.DescriptionEn,
DescriptionCn: d.DescriptionCn,
Year: d.Year,
Season: d.Season,
CollectionType: d.CollectionType,
SeasonCode: d.SeasonCode,
Cover: m.img.Compose(d.Cover, true), // 后台预览看高清
SourceURL: d.SourceURL,
ImageCount: d.ImageCount,
Status: d.Status,
Fields: []EditField{
{Name: "title_en", Label: "英文标题", Value: d.TitleEn, Type: "text"},
{Name: "title_cn", Label: "中文标题", Value: d.TitleCn, Type: "text"},
{Name: "description_en", Label: "英文描述", Value: d.DescriptionEn, Type: "textarea"},
{Name: "description_cn", Label: "中文描述", Value: d.DescriptionCn, Type: "textarea"},
{Name: "year", Label: "年份", Value: yearStr(d.Year), Type: "number"},
{Name: "season", Label: "季节", Value: d.Season, Type: "text"},
{Name: "collection_type", Label: "系列", Value: d.CollectionType, Type: "text"},
{Name: "season_code", Label: "季节码", Value: d.SeasonCode, Type: "text"},
},
}
for _, im := range imgs {
v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image, true), Name: im.Name, SortOrder: im.SortOrder})
}
return v, nil
}
func (m *runwayModule) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error {
return m.repo.UpdateDraft(ctx, id, fields)
}
func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
officialID, err := m.repo.SaveRunwayFromDraft(ctx, id)
if err != nil {
return 0, err
}
if err := m.repo.SetDraftStatus(ctx, id, model.DraftStatusApproved, reviewer, ""); err != nil {
return 0, err
}
return officialID, nil
}
func (m *runwayModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
return m.repo.SetDraftStatus(ctx, id, model.DraftStatusRejected, reviewer, reason)
}
func (m *runwayModule) CountPending(ctx context.Context) (int64, error) {
return m.repo.CountDraftPending(ctx, model.DraftStatusPending, &model.BrandRunwayDraft{})
}
// ── street 模块 ────────────────────────────────────────────────────────────────
type streetModule struct {
repo repository.ReviewRepository
img *imgurl.Composer
}
func (m *streetModule) Kind() string { return dto.IngestKindStreet }
func (m *streetModule) Label() string { return "街拍" }
func (m *streetModule) ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) {
rows, total, err := m.repo.ListStreetDrafts(ctx, status, page, size)
if err != nil {
return nil, 0, err
}
return m.toStreetCards(rows), total, nil
}
func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) {
d, err := m.repo.StreetDraftDetail(ctx, id)
if err != nil {
return nil, err
}
imgs, err := m.repo.StreetDraftImages(ctx, id)
if err != nil {
return nil, err
}
v := &DraftDetailView{
Kind: dto.IngestKindStreet,
ID: d.ID,
Title: d.Title,
Year: d.Year,
City: d.City,
Cover: m.img.Compose(d.Cover, true), // 后台预览看高清
SourceURL: d.SourceURL,
ImageCount: d.ImageCount,
Status: d.Status,
Fields: []EditField{
{Name: "title", Label: "标题", Value: d.Title, Type: "text"},
{Name: "year", Label: "年份", Value: yearStr(d.Year), Type: "number"},
{Name: "city", Label: "城市", Value: d.City, Type: "text"},
},
}
for _, im := range imgs {
v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image, true), Name: im.Name, SortOrder: im.SortOrder})
}
return v, nil
}
func (m *streetModule) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error {
return m.repo.UpdateStreetDraft(ctx, id, fields)
}
func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
officialID, err := m.repo.SaveStreetSnapFromDraft(ctx, id)
if err != nil {
return 0, err
}
if err := m.repo.SetStreetDraftStatus(ctx, id, model.DraftStatusApproved, reviewer, ""); err != nil {
return 0, err
}
return officialID, nil
}
func (m *streetModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
return m.repo.SetStreetDraftStatus(ctx, id, model.DraftStatusRejected, reviewer, reason)
}
func (m *streetModule) CountPending(ctx context.Context) (int64, error) {
return m.repo.CountDraftPending(ctx, model.DraftStatusPending, &model.StreetSnapDraft{})
}
// ── 卡片转换(保留原逻辑)──────────────────────────────────────────────────────
func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard {
cards := make([]DraftCard, 0, len(rows))
for _, d := range rows {
sub := d.SeasonCode
if d.CollectionType != "" {
sub += " · " + d.CollectionType
}
if sub == "" {
sub = "—"
}
cards = append(cards, DraftCard{
Kind: dto.IngestKindRunway,
ID: d.ID,
Title: d.TitleEn,
Subtitle: sub,
Cover: m.img.Compose(d.Cover, true), // 后台列表封面看高清
ImageCount: d.ImageCount,
Status: d.Status,
SourceURL: d.SourceURL,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
})
}
return cards
}
func (m *streetModule) toStreetCards(rows []model.StreetSnapDraft) []DraftCard {
cards := make([]DraftCard, 0, len(rows))
for _, d := range rows {
sub := d.City
if sub == "" {
sub = "—"
}
cards = append(cards, DraftCard{
Kind: dto.IngestKindStreet,
ID: d.ID,
Title: d.Title,
Subtitle: sub,
Cover: m.img.Compose(d.Cover, true), // 后台列表封面看高清
ImageCount: d.ImageCount,
Status: d.Status,
SourceURL: d.SourceURL,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
})
}
return cards
}
// yearStr 把 uint16 年份转字符串(0 显示为空)。
func yearStr(y uint16) string {
if y == 0 {
return ""
}
return strconv.FormatUint(uint64(y), 10)
}