update
This commit is contained in:
23
internal/dto/crawl.go
Normal file
23
internal/dto/crawl.go
Normal 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
34
internal/dto/ingest.go
Normal 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 下载)
|
||||||
|
}
|
||||||
1414
internal/handler/backstage_handler.go
Normal file
1414
internal/handler/backstage_handler.go
Normal file
File diff suppressed because it is too large
Load Diff
102
internal/handler/ingest_handler.go
Normal file
102
internal/handler/ingest_handler.go
Normal 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")
|
||||||
|
}
|
||||||
56
internal/middleware/ingest_auth.go
Normal file
56
internal/middleware/ingest_auth.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
57
internal/model/ingest_job.go
Normal file
57
internal/model/ingest_job.go
Normal 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
|
||||||
|
}
|
||||||
14
internal/model/ingest_nonce.go
Normal file
14
internal/model/ingest_nonce.go
Normal 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" }
|
||||||
54
internal/model/runway_draft.go
Normal file
54
internal/model/runway_draft.go
Normal 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"
|
||||||
|
)
|
||||||
43
internal/model/street_snap_draft.go
Normal file
43
internal/model/street_snap_draft.go
Normal 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
60
internal/pkg/hmac/hmac.go
Normal 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)))
|
||||||
|
}
|
||||||
78
internal/pkg/hmac/hmac_test.go
Normal file
78
internal/pkg/hmac/hmac_test.go
Normal 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:])
|
||||||
|
}
|
||||||
133
internal/pkg/imgurl/imgurl.go
Normal file
133
internal/pkg/imgurl/imgurl.go
Normal 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)
|
||||||
|
}
|
||||||
33
internal/pkg/imgurl/vip_check_test.go
Normal file
33
internal/pkg/imgurl/vip_check_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
71
internal/pkg/season/season.go
Normal file
71
internal/pkg/season/season.go
Normal 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
|
||||||
|
}
|
||||||
28
internal/pkg/season/season_test.go
Normal file
28
internal/pkg/season/season_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
196
internal/pkg/storage/qiniu.go
Normal file
196
internal/pkg/storage/qiniu.go
Normal 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
|
||||||
|
}
|
||||||
55
internal/pkg/storage/qiniu_purge_verify_test.go
Normal file
55
internal/pkg/storage/qiniu_purge_verify_test.go
Normal 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,说明首次删除未生效")
|
||||||
|
}
|
||||||
|
}
|
||||||
71
internal/pkg/storage/storage.go
Normal file
71
internal/pkg/storage/storage.go
Normal 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
|
||||||
|
}
|
||||||
622
internal/repository/ingest_repository.go
Normal file
622
internal/repository/ingest_repository.go
Normal 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
|
||||||
|
}
|
||||||
25
internal/repository/ingest_repository_test.go
Normal file
25
internal/repository/ingest_repository_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
52
internal/repository/media_repository.go
Normal file
52
internal/repository/media_repository.go
Normal 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
|
||||||
|
}
|
||||||
607
internal/repository/review_repository.go
Normal file
607
internal/repository/review_repository.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
129
internal/router/backstage.go
Normal file
129
internal/router/backstage.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
292
internal/router/backstage_test.go
Normal file
292
internal/router/backstage_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
53
internal/service/ingest_cleanup_test.go
Normal file
53
internal/service/ingest_cleanup_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
425
internal/service/ingest_service.go
Normal file
425
internal/service/ingest_service.go
Normal 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
|
||||||
|
}
|
||||||
86
internal/service/ingest_service_test.go
Normal file
86
internal/service/ingest_service_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
internal/service/media_purge.go
Normal file
45
internal/service/media_purge.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
491
internal/service/review_service.go
Normal file
491
internal/service/review_service.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user