From 10d8a96e8ccc102dd4f441213471ae7ef07a6675 Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Mon, 7 Sep 2026 00:04:01 +0800 Subject: [PATCH] update --- internal/dto/crawl.go | 23 + internal/dto/ingest.go | 34 + internal/handler/backstage_handler.go | 1414 +++++++++++++++++ internal/handler/ingest_handler.go | 102 ++ internal/middleware/ingest_auth.go | 56 + internal/model/ingest_job.go | 57 + internal/model/ingest_nonce.go | 14 + internal/model/runway_draft.go | 54 + internal/model/street_snap_draft.go | 43 + internal/pkg/hmac/hmac.go | 60 + internal/pkg/hmac/hmac_test.go | 78 + internal/pkg/imgurl/imgurl.go | 133 ++ internal/pkg/imgurl/vip_check_test.go | 33 + internal/pkg/season/season.go | 71 + internal/pkg/season/season_test.go | 28 + internal/pkg/storage/qiniu.go | 196 +++ .../pkg/storage/qiniu_purge_verify_test.go | 55 + internal/pkg/storage/storage.go | 71 + internal/repository/ingest_repository.go | 622 ++++++++ internal/repository/ingest_repository_test.go | 25 + internal/repository/media_repository.go | 52 + internal/repository/review_repository.go | 607 +++++++ internal/router/backstage.go | 129 ++ internal/router/backstage_test.go | 292 ++++ internal/service/ingest_cleanup_test.go | 53 + internal/service/ingest_service.go | 425 +++++ internal/service/ingest_service_test.go | 86 + internal/service/media_purge.go | 45 + internal/service/review_service.go | 491 ++++++ 29 files changed, 5349 insertions(+) create mode 100644 internal/dto/crawl.go create mode 100644 internal/dto/ingest.go create mode 100644 internal/handler/backstage_handler.go create mode 100644 internal/handler/ingest_handler.go create mode 100644 internal/middleware/ingest_auth.go create mode 100644 internal/model/ingest_job.go create mode 100644 internal/model/ingest_nonce.go create mode 100644 internal/model/runway_draft.go create mode 100644 internal/model/street_snap_draft.go create mode 100644 internal/pkg/hmac/hmac.go create mode 100644 internal/pkg/hmac/hmac_test.go create mode 100644 internal/pkg/imgurl/imgurl.go create mode 100644 internal/pkg/imgurl/vip_check_test.go create mode 100644 internal/pkg/season/season.go create mode 100644 internal/pkg/season/season_test.go create mode 100644 internal/pkg/storage/qiniu.go create mode 100644 internal/pkg/storage/qiniu_purge_verify_test.go create mode 100644 internal/pkg/storage/storage.go create mode 100644 internal/repository/ingest_repository.go create mode 100644 internal/repository/ingest_repository_test.go create mode 100644 internal/repository/media_repository.go create mode 100644 internal/repository/review_repository.go create mode 100644 internal/router/backstage.go create mode 100644 internal/router/backstage_test.go create mode 100644 internal/service/ingest_cleanup_test.go create mode 100644 internal/service/ingest_service.go create mode 100644 internal/service/ingest_service_test.go create mode 100644 internal/service/media_purge.go create mode 100644 internal/service/review_service.go diff --git a/internal/dto/crawl.go b/internal/dto/crawl.go new file mode 100644 index 0000000..479b496 --- /dev/null +++ b/internal/dto/crawl.go @@ -0,0 +1,23 @@ +package dto + +// CrawlBrand 爬虫取任务接口返回的单个品牌。 +// brand_uid 为 hashid 编码串(与对外一致),spider 直接拿它上送 ingest,无需自己编码; +// name 为英文品牌名,供 spider 拼接到 vogue 抓取 URL(如 /fashion-shows/designer/)。 +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"` // 命中数量,便于爬虫打日志观察命中率 +} diff --git a/internal/dto/ingest.go b/internal/dto/ingest.go new file mode 100644 index 0000000..90bbbcf --- /dev/null +++ b/internal/dto/ingest.go @@ -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 下载) +} diff --git a/internal/handler/backstage_handler.go b/internal/handler/backstage_handler.go new file mode 100644 index 0000000..bc1b504 --- /dev/null +++ b/internal/handler/backstage_handler.go @@ -0,0 +1,1414 @@ +package handler + +import ( + "bytes" + "html/template" + "net/http" + "strconv" + "time" + + "fashionapi/internal/dto" + "fashionapi/internal/middleware" + "fashionapi/internal/pkg/hashid" + "fashionapi/internal/service" + + "github.com/gin-gonic/gin" +) + +// adminCookie 是后台会话令牌(access token)的 cookie 名。 +// 仅后台自身读写,路径限定 /admin,HttpOnly,避免被前端 JS 读到。 +const adminCookie = "admin_session" + +// BackstageHandler 管理后台(Backstage)页面与接口。 +// +// 定位:朴素、实用、代码简洁的服务端渲染后台,复用现有 service 层读库与鉴权, +// 不引入额外前端框架。会话用 access token 存 HttpOnly cookie,由 router 层中间件校验。 +type BackstageHandler struct { + auth service.AuthService + brand service.BrandService + article service.ArticleService + street service.StreetSnapService + review service.ReviewService + ingest *service.IngestService +} + +// NewBackstageHandler 创建后台 handler。 +func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService) *BackstageHandler { + return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest} +} + +// renderHTML 用单文件模板字符串渲染 HTML(不依赖外部模板文件,保持自包含)。 +func renderHTML(c *gin.Context, status int, tpl string, data any) { + t, err := template.New("t").Parse(tpl) + if err != nil { + c.String(http.StatusInternalServerError, "template error: "+err.Error()) + return + } + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + c.String(http.StatusInternalServerError, "render error: "+err.Error()) + return + } + c.Data(status, "text/html; charset=utf-8", buf.Bytes()) +} + +// LoginPage 渲染登录页;已登录则直接进后台首页。 +func (h *BackstageHandler) LoginPage(c *gin.Context) { + if token, err := c.Cookie(adminCookie); err == nil && token != "" { + c.Redirect(http.StatusFound, "/admin/") + return + } + renderHTML(c, http.StatusOK, tplLogin, gin.H{}) +} + +// Login 处理登录表单:校验账号密码 → 下发 access token 到 cookie → 跳后台首页。 +// 登录失败则回显错误,不暴露账号是否存在(统一「账号或密码错误」)。 +func (h *BackstageHandler) Login(c *gin.Context) { + account := c.PostForm("account") + password := c.PostForm("password") + if account == "" || password == "" { + renderHTML(c, http.StatusOK, tplLogin, gin.H{"Error": "请输入账号和密码", "Account": account}) + return + } + access, _, _, _, err := h.auth.Login(c.Request.Context(), account, password) + if err != nil { + renderHTML(c, http.StatusOK, tplLogin, gin.H{"Error": "账号或密码错误", "Account": account}) + return + } + // access token 默认 7 天有效;cookie 生命周期与之对齐,过期后中间件自动踢回登录页。 + c.SetCookie(adminCookie, access, 7*24*3600, "/admin", "", false, true) + c.Redirect(http.StatusFound, "/admin/") +} + +// Logout 清除会话 cookie 并跳登录页。 +func (h *BackstageHandler) Logout(c *gin.Context) { + c.SetCookie(adminCookie, "", -1, "/admin", "", false, true) + c.Redirect(http.StatusFound, "/admin/login") +} + +// Dashboard 后台首页:展示当前账号与功能入口(含各类型待审草稿数)。 +func (h *BackstageHandler) Dashboard(c *gin.Context) { + username, _ := c.Get(middleware.ContextUsername) + kinds := h.review.KindTabs(c.Request.Context()) + var pending int64 + for _, k := range kinds { + pending += k.Pending + } + renderHTML(c, http.StatusOK, tplDashboard, gin.H{"Username": username, "PendingReviews": pending, "Kinds": kinds}) +} + +// Users 用户管理列表:列出全量用户并支持提级 / 降为 VIP。 +func (h *BackstageHandler) Users(c *gin.Context) { + users, err := h.auth.ListUsers(c.Request.Context()) + if err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + renderHTML(c, http.StatusOK, tplUsers, gin.H{"Users": users}) +} + +// UserSetTier 把某用户提级 / 降为指定等级(free/vip)。 +func (h *BackstageHandler) UserSetTier(c *gin.Context) { + id := atoiDefault(c.Param("id"), 0) + if id == 0 { + renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "用户不存在"}) + return + } + tier := c.PostForm("tier") + if err := h.auth.SetTier(c.Request.Context(), uint32(id), tier); err != nil { + renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": err.Error()}) + return + } + c.Redirect(http.StatusFound, "/admin/users") +} + +// Brands 品牌管理列表:支持关键词搜索、A-Z 字母索引、分页,复用 BrandService.List。 +func (h *BackstageHandler) Brands(c *gin.Context) { + q := dto.BrandQuery{ + Keyword: c.Query("keyword"), + Letter: c.Query("letter"), + Page: atoiDefault(c.Query("page"), 1), + Locale: "en", + } + items, total, err := h.brand.List(c.Request.Context(), q) + if err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + const size = 30 + page := q.Page + if page < 1 { + page = 1 + } + renderHTML(c, http.StatusOK, tplBrands, gin.H{ + "Items": items, + "Total": total, + "Page": page, + "PrevPage": page - 1, + "NextPage": page + 1, + "HasPrev": page > 1, + "HasNext": int64(page*size) < total, + "Keyword": q.Keyword, + "Letter": q.Letter, + }) +} + +// BrandNew 渲染品牌新建表单。 +func (h *BackstageHandler) BrandNew(c *gin.Context) { + renderHTML(c, http.StatusOK, tplBrandForm, gin.H{}) +} + +// BrandCreate 处理品牌新建表单。 +func (h *BackstageHandler) BrandCreate(c *gin.Context) { + nameEn := c.PostForm("name_en") + nameCn := c.PostForm("name_cn") + if nameEn == "" && nameCn == "" { + renderHTML(c, http.StatusOK, tplBrandForm, gin.H{"Error": "英文名与中文名至少填一个", "NameEn": nameEn, "NameCn": nameCn}) + return + } + if _, err := h.brand.Create(c.Request.Context(), nameEn, nameCn); err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + c.Redirect(http.StatusFound, "/admin/brands") +} + +// BrandEdit 渲染品牌编辑表单(按编码 id 取现有值)。 +func (h *BackstageHandler) BrandEdit(c *gin.Context) { + b, err := h.brand.Get(c.Request.Context(), c.Param("id")) + if err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "品牌不存在或已删除: " + c.Param("id")}) + return + } + renderHTML(c, http.StatusOK, tplBrandForm, gin.H{"UID": c.Param("id"), "NameEn": b.NameEn, "NameCn": b.NameCn}) +} + +// BrandUpdate 处理品牌编辑表单。 +func (h *BackstageHandler) BrandUpdate(c *gin.Context) { + uid := c.Param("id") + nameEn := c.PostForm("name_en") + nameCn := c.PostForm("name_cn") + if nameEn == "" && nameCn == "" { + renderHTML(c, http.StatusOK, tplBrandForm, gin.H{"UID": uid, "Error": "英文名与中文名至少填一个", "NameEn": nameEn, "NameCn": nameCn}) + return + } + if err := h.brand.Update(c.Request.Context(), uid, nameEn, nameCn); err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + c.Redirect(http.StatusFound, "/admin/brands") +} + +// Runways 走秀档案管理列表:按系列/季节/年份/排序筛选,复用 ArticleService.AdminList(含管理字段)。 +func (h *BackstageHandler) Runways(c *gin.Context) { + q := dto.ArticleQuery{ + Collection: c.Query("collection"), + Season: c.Query("season"), + Year: atoiDefault(c.Query("year"), 0), + Sort: c.Query("sort"), + Page: atoiDefault(c.Query("page"), 1), + Locale: "en", + } + items, total, err := h.article.AdminList(c.Request.Context(), q) + if err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + page := q.Page + if page < 1 { + page = 1 + } + renderHTML(c, http.StatusOK, tplRunways, gin.H{ + "Items": items, + "Total": total, + "Page": page, + "PrevPage": page - 1, + "NextPage": page + 1, + "HasPrev": page > 1, + "HasNext": int64(page*24) < total, + "Collection": q.Collection, + "Season": q.Season, + "Year": q.Year, + "Sort": q.Sort, + }) +} + +// RunwayDetail 走秀详情:展示完整图集,复用 ArticleService.Detail。 +// 路由 :id 是带 "r" 前缀的 hashid,需先解码成数字主键再查库(与公开接口 article_handler 保持一致)。 +func (h *BackstageHandler) RunwayDetail(c *gin.Context) { + raw := c.Param("id") + _, numeric, err := hashid.DecodeTyped(raw) + if err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw}) + return + } + art, err := h.article.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), "en", true) + if err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw}) + return + } + renderHTML(c, http.StatusOK, tplRunwayDetail, gin.H{"Article": art}) +} + +// StreetSnaps 街拍管理列表:按年份/城市/排序筛选,复用 StreetSnapService.List。 +func (h *BackstageHandler) StreetSnaps(c *gin.Context) { + q := dto.StreetSnapQuery{ + Year: atoiDefault(c.Query("year"), 0), + City: c.Query("city"), + Sort: c.Query("sort"), + Page: atoiDefault(c.Query("page"), 1), + Locale: "en", + } + items, total, err := h.street.List(c.Request.Context(), q) + if err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + page := q.Page + if page < 1 { + page = 1 + } + renderHTML(c, http.StatusOK, tplStreetSnaps, gin.H{ + "Items": items, + "Total": total, + "Page": page, + "PrevPage": page - 1, + "NextPage": page + 1, + "HasPrev": page > 1, + "HasNext": int64(page*24) < total, + "Year": q.Year, + "City": q.City, + "Sort": q.Sort, + }) +} + +// StreetSnapDetail 街拍详情:展示完整图集,复用 StreetSnapService.Detail。 +// 路由 :id 是带 "s" 前缀的 hashid,需先解码成数字主键再查库(与公开接口 street_snap_handler 保持一致)。 +func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) { + raw := c.Param("id") + _, numeric, err := hashid.DecodeTyped(raw) + if err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw}) + return + } + snap, err := h.street.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), true) + if err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw}) + return + } + renderHTML(c, http.StatusOK, tplStreetSnapDetail, gin.H{"Snap": snap}) +} + +// ── 审核草稿 ─────────────────────────────────────────────────────────────────── + +// ReviewList 待审草稿列表:按类型(kind,缺省全部)与状态筛选。 +// 类型标签由审核注册表生成(见 ReviewService.KindTabs),新增模块自动出现,无需改此分支。 +func (h *BackstageHandler) ReviewList(c *gin.Context) { + kind := c.Param("kind") + if kind == "" { + kind = c.Query("kind") + } + status := c.Query("status") + page := atoiDefault(c.Query("page"), 1) + if page < 1 { + page = 1 + } + // 校验 kind 是否在注册表中;未知 kind 退回「全部」。 + kinds := h.review.KindTabs(c.Request.Context()) + registered := map[string]bool{} + for _, t := range kinds { + if t.Kind != "" { + registered[t.Kind] = true + } + } + if kind != "" && kind != "all" && !registered[kind] { + kind = "" + } + drafts, total, err := h.review.ListDrafts(c.Request.Context(), kind, status, page, 20) + if err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + // 分页链接基址:带 kind 时追加 /{kind},否则裸 /admin/reviews + listBase := "" + if kind != "" && kind != "all" { + listBase = "/" + kind + } + renderHTML(c, http.StatusOK, tplReviewList, gin.H{ + "Drafts": drafts, + "Total": total, + "Page": page, + "Status": status, + "Kind": kind, + "Kinds": kinds, + "ListBase": listBase, + "HasPrev": page > 1, + "HasNext": int64(page*20) < total, + "PrevPage": page - 1, + "NextPage": page + 1, + }) +} + +// ReviewDetail 草稿详情:展示预览 + 可编辑字段 + 通过/驳回操作。 +func (h *BackstageHandler) ReviewDetail(c *gin.Context) { + kind := c.Param("kind") + id := atoiDefault(c.Param("id"), 0) + if id == 0 { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "草稿不存在: " + c.Param("id")}) + return + } + draft, err := h.review.DraftDetail(c.Request.Context(), kind, uint32(id)) + if err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "草稿不存在或已删除: " + c.Param("id")}) + return + } + // 有品牌归属的模块(如走秀)解析品牌名;其余模块 BrandID=0 跳过。 + brandName := "" + if draft.BrandID > 0 { + if b, berr := h.brand.Get(c.Request.Context(), hashid.Encode(draft.BrandID)); berr == nil { + brandName = b.NameEn + if brandName == "" { + brandName = b.NameCn + } + } + } + renderHTML(c, http.StatusOK, tplReviewDetail, gin.H{ + "Draft": draft, + "BrandName": brandName, + }) +} + +// ReviewApprove 通过:先应用审核人的字段微调,再晋升到正式表。 +func (h *BackstageHandler) ReviewApprove(c *gin.Context) { + kind := c.Param("kind") + id := atoiDefault(c.Param("id"), 0) + if id == 0 { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "草稿不存在: " + c.Param("id")}) + return + } + fields := draftFormFields(c) + if err := h.review.UpdateDraft(c.Request.Context(), kind, uint32(id), fields); err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + username, _ := c.Get(middleware.ContextUsername) + if _, err := h.review.Approve(c.Request.Context(), kind, uint32(id), usernameString(username)); err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + c.Redirect(http.StatusFound, reviewListURL(kind)) +} + +// ReviewReject 驳回:仅记录状态与理由。 +func (h *BackstageHandler) ReviewReject(c *gin.Context) { + kind := c.Param("kind") + id := atoiDefault(c.Param("id"), 0) + if id == 0 { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "草稿不存在: " + c.Param("id")}) + return + } + reason := c.PostForm("reason") + username, _ := c.Get(middleware.ContextUsername) + if err := h.review.Reject(c.Request.Context(), kind, uint32(id), usernameString(username), reason); err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + c.Redirect(http.StatusFound, reviewListURL(kind)) +} + +// ReviewDraftImageDelete 软删审核草稿里的单张图片(审核阶段剔除坏图,不删七牛文件)。 +// 校验 kind 合法后委派给审核服务,成功后跳回草稿详情页。 +func (h *BackstageHandler) ReviewDraftImageDelete(c *gin.Context) { + kind := c.Param("kind") + id := atoiDefault(c.Param("id"), 0) + imgID := atoiDefault(c.Param("img"), 0) + if id == 0 || imgID == 0 { + renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "参数无效"}) + return + } + if err := h.review.DeleteDraftImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + c.Redirect(http.StatusFound, "/admin/reviews/"+kind+"/"+c.Param("id")) +} + +// reviewListURL 根据 kind 生成审核列表跳转地址(带 kind 段或裸 /admin/reviews)。 +func reviewListURL(kind string) string { + if kind == dto.IngestKindRunway || kind == dto.IngestKindStreet { + return "/admin/reviews/" + kind + } + return "/admin/reviews" +} + +// ── 爬虫入库任务监控 ────────────────────────────────────────────────────────── + +// ingestJobView 是入库任务在监控页的展示结构(已把 unix 时间格式化为字符串, +// 避免 html/template 默认无日期函数的限制)。 +type ingestJobView struct { + ID uint32 + Status string + Attempts uint8 + SourceURL string + LastError string + UpdatedAt string +} + +// IngestJobs 列出最近的入库任务,展示状态 / 重试次数 / 来源 / 错误 / 时间。 +func (h *BackstageHandler) IngestJobs(c *gin.Context) { + jobs, err := h.ingest.ListJobs(c.Request.Context(), 200) + if err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + views := make([]ingestJobView, 0, len(jobs)) + for _, j := range jobs { + ua := "" + if j.UpdatedAt > 0 { + ua = time.Unix(int64(j.UpdatedAt), 0).Format("2006-01-02 15:04:05") + } + views = append(views, ingestJobView{ + ID: j.ID, + Status: j.Status, + Attempts: j.Attempts, + SourceURL: j.SourceURL, + LastError: j.LastError, + UpdatedAt: ua, + }) + } + renderHTML(c, http.StatusOK, tplIngestJobs, gin.H{"Jobs": views}) +} + +// IngestJobRetry 把一条 failed 任务重新入队(重置为 pending),worker 会在下一个周期重新处理。 +func (h *BackstageHandler) IngestJobRetry(c *gin.Context) { + id := atoiDefault(c.Param("id"), 0) + if id == 0 { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "任务不存在: " + c.Param("id")}) + return + } + if err := h.ingest.RetryJob(c.Request.Context(), uint32(id)); err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + c.Redirect(http.StatusFound, "/admin/ingest-jobs") +} + +// draftFormFields 从表单收集可编辑草稿字段(runway + street 一并收集, +// 最终白名单在仓储层按 kind 强制,非本 kind 的字段会被忽略)。 +func draftFormFields(c *gin.Context) map[string]any { + f := map[string]any{} + if v := c.PostForm("title_en"); v != "" { + f["title_en"] = v + } + if v := c.PostForm("title_cn"); v != "" { + f["title_cn"] = v + } + f["description_en"] = c.PostForm("description_en") + f["description_cn"] = c.PostForm("description_cn") + if v := c.PostForm("season"); v != "" { + f["season"] = v + } + if v := c.PostForm("collection_type"); v != "" { + f["collection_type"] = v + } + if v := c.PostForm("season_code"); v != "" { + f["season_code"] = v + } + if v := c.PostForm("title"); v != "" { + f["title"] = v + } + if v := c.PostForm("city"); v != "" { + f["city"] = v + } + if v := c.PostForm("year"); v != "" { + if y, err := strconv.Atoi(v); err == nil { + f["year"] = uint16(y) + } + } + return f +} + +// usernameString 把中间件注入的 username(any)兜底成字符串。 +func usernameString(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +// ── 走秀编辑 ───────────────────────────────────────────────────────────────────── + +// RunwayEdit 渲染走秀编辑表单(预填现有字段 + 图片管理)。 +func (h *BackstageHandler) RunwayEdit(c *gin.Context) { + raw := c.Param("id") + _, numeric, err := hashid.DecodeTyped(raw) + if err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw}) + return + } + rw, imgs, err := h.article.GetForEdit(c.Request.Context(), numeric) + if err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw}) + return + } + brandName := "" + if b, berr := h.brand.Get(c.Request.Context(), hashid.Encode(rw.BrandID)); berr == nil { + brandName = b.NameEn + if brandName == "" { + brandName = b.NameCn + } + } + renderHTML(c, http.StatusOK, tplRunwayEdit, gin.H{ + "UID": raw, + "Runway": rw, + "Images": imgs, + "BrandName": brandName, + "IsDown": rw.IsDeleted == 1, + }) +} + +// RunwayUpdate 应用走秀字段编辑。 +func (h *BackstageHandler) RunwayUpdate(c *gin.Context) { + raw := c.Param("id") + _, numeric, err := hashid.DecodeTyped(raw) + if err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw}) + return + } + year := uint16(0) + if v := c.PostForm("year"); v != "" { + if y, e := strconv.Atoi(v); e == nil { + year = uint16(y) + } + } + fields := map[string]any{ + "title_en": c.PostForm("title_en"), + "title_cn": c.PostForm("title_cn"), + "description_en": c.PostForm("description_en"), + "description_cn": c.PostForm("description_cn"), + "season": c.PostForm("season"), + "collection_type": c.PostForm("collection_type"), + "season_code": c.PostForm("season_code"), + "year": year, + } + if err := h.article.UpdateRunway(c.Request.Context(), numeric, fields); err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + c.Redirect(http.StatusFound, "/admin/runways/"+raw) +} + +// RunwayImageDelete 软删单张走秀图片。 +func (h *BackstageHandler) RunwayImageDelete(c *gin.Context) { + raw := c.Param("id") + if _, _, err := hashid.DecodeTyped(raw); err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw}) + return + } + imgID := atoiDefault(c.Param("img"), 0) + if imgID == 0 { + renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "图片标识无效"}) + return + } + if err := h.article.DeleteImage(c.Request.Context(), uint32(imgID)); err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + c.Redirect(http.StatusFound, "/admin/runways/"+raw) +} + +// RunwaySetCover 把某张图片设为封面。 +func (h *BackstageHandler) RunwaySetCover(c *gin.Context) { + raw := c.Param("id") + _, numeric, err := hashid.DecodeTyped(raw) + if err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw}) + return + } + image := c.PostForm("image") + if image == "" { + renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "缺少 image 参数"}) + return + } + if err := h.article.SetCover(c.Request.Context(), numeric, image); err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + c.Redirect(http.StatusFound, "/admin/runways/"+raw) +} + +// RunwayToggleDeleted 上下架(下架=is_deleted=1 对外不可见)。 +func (h *BackstageHandler) RunwayToggleDeleted(c *gin.Context) { + raw := c.Param("id") + _, numeric, err := hashid.DecodeTyped(raw) + if err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw}) + return + } + deleted := uint8(0) + if c.PostForm("deleted") == "1" { + deleted = 1 + } + if err := h.article.SetDeleted(c.Request.Context(), numeric, deleted); err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + c.Redirect(http.StatusFound, "/admin/runways/"+raw) +} + +// ── 街拍编辑 ───────────────────────────────────────────────────────────────────── + +// StreetSnapEdit 渲染街拍编辑表单。 +func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) { + raw := c.Param("id") + _, numeric, err := hashid.DecodeTyped(raw) + if err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw}) + return + } + snap, imgs, err := h.street.GetForEdit(c.Request.Context(), numeric) + if err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw}) + return + } + renderHTML(c, http.StatusOK, tplSnapEdit, gin.H{ + "UID": raw, + "Snap": snap, + "Images": imgs, + "IsDown": snap.IsDeleted == 1, + }) +} + +// StreetSnapUpdate 应用街拍字段编辑。 +func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) { + raw := c.Param("id") + _, numeric, err := hashid.DecodeTyped(raw) + if err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw}) + return + } + year := uint16(0) + if v := c.PostForm("year"); v != "" { + if y, e := strconv.Atoi(v); e == nil { + year = uint16(y) + } + } + fields := map[string]any{ + "title": c.PostForm("title"), + "city": c.PostForm("city"), + "year": year, + } + if err := h.street.UpdateSnap(c.Request.Context(), numeric, fields); err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw) +} + +// StreetSnapImageDelete 软删单张街拍图片。 +func (h *BackstageHandler) StreetSnapImageDelete(c *gin.Context) { + raw := c.Param("id") + if _, _, err := hashid.DecodeTyped(raw); err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw}) + return + } + imgID := atoiDefault(c.Param("img"), 0) + if imgID == 0 { + renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "图片标识无效"}) + return + } + if err := h.street.DeleteImage(c.Request.Context(), uint32(imgID)); err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw) +} + +// StreetSnapSetCover 把某张图片设为封面。 +func (h *BackstageHandler) StreetSnapSetCover(c *gin.Context) { + raw := c.Param("id") + _, numeric, err := hashid.DecodeTyped(raw) + if err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw}) + return + } + image := c.PostForm("image") + if image == "" { + renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "缺少 image 参数"}) + return + } + if err := h.street.SetCover(c.Request.Context(), numeric, image); err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw) +} + +// StreetSnapToggleDeleted 上下架。 +func (h *BackstageHandler) StreetSnapToggleDeleted(c *gin.Context) { + raw := c.Param("id") + _, numeric, err := hashid.DecodeTyped(raw) + if err != nil { + renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw}) + return + } + deleted := uint8(0) + if c.PostForm("deleted") == "1" { + deleted = 1 + } + if err := h.street.SetDeleted(c.Request.Context(), numeric, deleted); err != nil { + renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()}) + return + } + c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw) +} + +// atoiDefault 把字符串转 int,失败回落默认值。 +func atoiDefault(s string, def int) int { + if s == "" { + return def + } + n, err := strconv.Atoi(s) + if err != nil { + return def + } + return n +} + +// ── 朴素 HTML 模板(无外部依赖,样式从简但够用)───────────────────────────── + +const tplLogin = ` + +管理后台 · 登录 + +

管理后台登录

+
+{{if .Error}}
{{.Error}}
{{end}} + + + +
` + +const tplDashboard = ` + +管理后台 + + +
管理后台 · Fashion Archive退出({{.Username}})
+
+

欢迎,{{.Username}}

+{{if .Kinds}}
{{range .Kinds}}{{if ne .Kind ""}}{{.Label}}:待审 {{.Pending}}{{end}}{{end}}
{{end}} + +
` + +const tplUsers = ` + +用户管理 · 管理后台 + + +
管理后台 · 用户管理返回首页 · 退出
+
+

用户管理({{len .Users}})

+

VIP 用户在图集详情页可看高清原图,普通 / 未登录用户只看 normal 普通图。

+ + + +{{range .Users}} + + + + + +{{end}} + +
ID用户名邮箱等级操作
{{.ID}}{{.Username}}{{.Email}}{{if eq .Tier "vip"}}VIP{{else}}FREE{{end}} +
+ + +
+
+
` + +const tplBrands = ` + +品牌管理 · 管理后台 + + +
管理后台 · 品牌管理+ 新建 · 返回首页 · 退出
+
+

品牌列表

+
+ + +{{if .Keyword}}清除{{end}} +
+
+全部 +中文 +A +B +C +D +E +F +G +H +I +J +K +L +M +N +O +P +Q +R +S +T +U +V +W +X +Y +Z +
+ + + +{{range .Items}}{{end}} + +
编码 ID英文名中文名
{{.UID}}{{.NameEn}}{{.NameCn}}编辑
+
+{{if .HasPrev}}上一页{{else}}上一页{{end}} +第 {{.Page}} 页 · 共 {{.Total}} 条 +{{if .HasNext}}下一页{{else}}下一页{{end}} +
+
` + +const tplRunways = ` + +走秀档案 · 管理后台 + + +
管理后台 · 走秀档案返回首页 · 退出
+
+

走秀列表

+
+ + + + + +清除 +
+ + + +{{range .Items}}{{end}} + +
编码 ID品牌标题系列季节年份季节码图数
{{.UID}}{{.BrandName}}{{.Title}}{{.CollectionType}}{{.Season}}{{.Year}}{{.SeasonCode}}{{.ImageCount}}查看
+
+{{if .HasPrev}}上一页{{else}}上一页{{end}} +第 {{.Page}} 页 · 共 {{.Total}} 条 +{{if .HasNext}}下一页{{else}}下一页{{end}} +
+
` + +const tplRunwayDetail = ` + +走秀详情 · 管理后台 + + +
管理后台 · 走秀详情返回列表 · 编辑 · 退出
+
+

编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}

+

{{.Article.Title}}

+

来源:{{if .Article.SourceURL}}{{.Article.SourceURL}}{{else}}—{{end}}

+
+{{range .Article.Images}}
{{.Name}}
{{end}} +
+
` + +const tplStreetSnaps = ` + +街拍 · 管理后台 + + +
管理后台 · 街拍返回首页 · 退出
+
+

街拍列表

+
+ + + + +清除 +
+ + + +{{range .Items}}{{end}} + +
编码 ID标题图片数
{{.UID}}{{.Title}}{{.ImageCount}}查看
+
+{{if .HasPrev}}上一页{{else}}上一页{{end}} +第 {{.Page}} 页 · 共 {{.Total}} 条 +{{if .HasNext}}下一页{{else}}下一页{{end}} +
+
` + +const tplStreetSnapDetail = ` + +街拍详情 · 管理后台 + + +
管理后台 · 街拍详情返回列表 · 编辑 · 退出
+
+

编码 {{.Snap.UID}}

+

{{.Snap.Title}}

+
+{{range .Snap.Images}}
{{.Name}}
{{end}} +
+
` + +const tplBrandForm = ` + +{{if .UID}}编辑品牌{{else}}新建品牌{{end}} · 管理后台 + + +
管理后台 · {{if .UID}}编辑品牌{{else}}新建品牌{{end}}返回列表 · 退出
+
+

{{if .UID}}编辑品牌{{else}}新建品牌{{end}}

+
+{{if .Error}}
{{.Error}}
{{end}} + + + +
+
` + +const tplReviewList = ` + +待审草稿 · 管理后台 + + +
管理后台 · 待审草稿返回首页 · 退出
+
+

待审草稿(共 {{.Total}} 条)

+ + + + + +{{range .Drafts}} + + + + + + + +{{end}} + +
ID类型标题副标题图数状态
{{.ID}}{{if eq .Kind "street"}}{{.Kind}}{{else}}{{.Kind}}{{end}}{{.Title}}{{.Subtitle}}{{.ImageCount}}{{.Status}}查看/审核
+
+{{if .HasPrev}}上一页{{else}}上一页{{end}} +第 {{.Page}} 页 +{{if .HasNext}}下一页{{else}}下一页{{end}} +
+
` + +const tplReviewDetail = ` + +草稿审核 · 管理后台 + + +
管理后台 · 草稿审核 #{{.Draft.ID}}返回列表 · 退出
+
+

类型 {{.Draft.Kind}}{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 {{.Draft.Status}} · 来源:{{if .Draft.SourceURL}}{{.Draft.SourceURL}}{{else}}—{{end}}

+
+{{range .Draft.Fields}}
{{if eq .Type "textarea"}}{{else}}{{end}}
{{end}} + +
+
+ + +
+

图片管理({{len .Draft.Images}} 张)

+
+{{range .Draft.Images}}
+ +{{.Name}} +
+
+
+
{{end}} +
+
` + +const tplRunwayEdit = ` + +编辑走秀 · 管理后台 + + +
管理后台 · 编辑走秀 {{.UID}}返回详情 · 退出
+
+

品牌 {{.BrandName}}{{if .IsDown}} · 已下架{{end}}

+
+
+
+
+
+ +
+ +

图片管理({{len .Images}} 张)

+
+{{range .Images}}
+ +
+
+
+
+
{{end}} +
+ +
+{{if .IsDown}}{{else}}{{end}} +
+

下架会级联软删全部图片,并异步把七牛孤儿图清理任务入队(按引用计数判定,共享图不误删)。

+
` + +const tplSnapEdit = ` + +编辑街拍 · 管理后台 + + +
管理后台 · 编辑街拍 {{.UID}}返回详情 · 退出
+
+

{{if .IsDown}}已下架{{end}}

+
+
+
+ +
+ +

图片管理({{len .Images}} 张)

+
+{{range .Images}}
+ +
+
+
+
+
{{end}} +
+ +
+{{if .IsDown}}{{else}}{{end}} +
+

下架会级联软删全部图片,并异步把七牛孤儿图清理任务入队(按引用计数判定,共享图不误删)。

+
` + +const tplIngestJobs = ` + +入库任务 · 管理后台 + + +
管理后台 · 爬虫入库任务返回首页 · 退出
+
+

爬虫入库任务(最近 {{len .Jobs}} 条)

+

worker 每 3 秒扫描一次 pending 任务并处理;failed 可一键重新入队。

+ + + +{{range .Jobs}} + + + + + + + +{{end}} + +
ID状态重试来源 URL错误信息更新时间操作
{{.ID}}{{.Status}}{{.Attempts}}{{.SourceURL}}{{.LastError}}{{.UpdatedAt}}{{if eq .Status "failed"}}
{{else}}—{{end}}
+
` + +const tplError = `错误 + +

出错了

{{.Error}}

+

返回首页

` diff --git a/internal/handler/ingest_handler.go b/internal/handler/ingest_handler.go new file mode 100644 index 0000000..400de65 --- /dev/null +++ b/internal/handler/ingest_handler.go @@ -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= 单品牌调试:解析失败或非正数时回落全量。 + 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") +} diff --git a/internal/middleware/ingest_auth.go b/internal/middleware/ingest_auth.go new file mode 100644 index 0000000..44f1f07 --- /dev/null +++ b/internal/middleware/ingest_auth.go @@ -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() + } +} diff --git a/internal/model/ingest_job.go b/internal/model/ingest_job.go new file mode 100644 index 0000000..4dbb2fc --- /dev/null +++ b/internal/model/ingest_job.go @@ -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 +} diff --git a/internal/model/ingest_nonce.go b/internal/model/ingest_nonce.go new file mode 100644 index 0000000..2c2c282 --- /dev/null +++ b/internal/model/ingest_nonce.go @@ -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" } diff --git a/internal/model/runway_draft.go b/internal/model/runway_draft.go new file mode 100644 index 0000000..3fbaaf9 --- /dev/null +++ b/internal/model/runway_draft.go @@ -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" +) diff --git a/internal/model/street_snap_draft.go b/internal/model/street_snap_draft.go new file mode 100644 index 0000000..d90670d --- /dev/null +++ b/internal/model/street_snap_draft.go @@ -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" } diff --git a/internal/pkg/hmac/hmac.go b/internal/pkg/hmac/hmac.go new file mode 100644 index 0000000..871af65 --- /dev/null +++ b/internal/pkg/hmac/hmac.go @@ -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))) +} diff --git a/internal/pkg/hmac/hmac_test.go b/internal/pkg/hmac/hmac_test.go new file mode 100644 index 0000000..fd1758d --- /dev/null +++ b/internal/pkg/hmac/hmac_test.go @@ -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:]) +} diff --git a/internal/pkg/imgurl/imgurl.go b/internal/pkg/imgurl/imgurl.go new file mode 100644 index 0000000..3c971ee --- /dev/null +++ b/internal/pkg/imgurl/imgurl.go @@ -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=" 做 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=",对其做 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) +} diff --git a/internal/pkg/imgurl/vip_check_test.go b/internal/pkg/imgurl/vip_check_test.go new file mode 100644 index 0000000..8338e53 --- /dev/null +++ b/internal/pkg/imgurl/vip_check_test.go @@ -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") + } +} diff --git a/internal/pkg/season/season.go b/internal/pkg/season/season.go new file mode 100644 index 0000000..ff4bbbc --- /dev/null +++ b/internal/pkg/season/season.go @@ -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 +} diff --git a/internal/pkg/season/season_test.go b/internal/pkg/season/season_test.go new file mode 100644 index 0000000..033c485 --- /dev/null +++ b/internal/pkg/season/season_test.go @@ -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) + } + } +} diff --git a/internal/pkg/storage/qiniu.go b/internal/pkg/storage/qiniu.go new file mode 100644 index 0000000..a78a7a4 --- /dev/null +++ b/internal/pkg/storage/qiniu.go @@ -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 // 注意前缀是 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 +} diff --git a/internal/pkg/storage/qiniu_purge_verify_test.go b/internal/pkg/storage/qiniu_purge_verify_test.go new file mode 100644 index 0000000..ebef93f --- /dev/null +++ b/internal/pkg/storage/qiniu_purge_verify_test.go @@ -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,说明首次删除未生效") + } +} diff --git a/internal/pkg/storage/storage.go b/internal/pkg/storage/storage.go new file mode 100644 index 0000000..63f2dcd --- /dev/null +++ b/internal/pkg/storage/storage.go @@ -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 +} diff --git a/internal/repository/ingest_repository.go b/internal/repository/ingest_repository.go new file mode 100644 index 0000000..23819f3 --- /dev/null +++ b/internal/repository/ingest_repository.go @@ -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 +} diff --git a/internal/repository/ingest_repository_test.go b/internal/repository/ingest_repository_test.go new file mode 100644 index 0000000..3a5fa44 --- /dev/null +++ b/internal/repository/ingest_repository_test.go @@ -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") + } +} diff --git a/internal/repository/media_repository.go b/internal/repository/media_repository.go new file mode 100644 index 0000000..b0f7821 --- /dev/null +++ b/internal/repository/media_repository.go @@ -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 +} diff --git a/internal/repository/review_repository.go b/internal/repository/review_repository.go new file mode 100644 index 0000000..e196930 --- /dev/null +++ b/internal/repository/review_repository.go @@ -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 + } +} diff --git a/internal/router/backstage.go b/internal/router/backstage.go new file mode 100644 index 0000000..5891136 --- /dev/null +++ b/internal/router/backstage.go @@ -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() + } +} diff --git a/internal/router/backstage_test.go b/internal/router/backstage_test.go new file mode 100644 index 0000000..b392e24 --- /dev/null +++ b/internal/router/backstage_test.go @@ -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()) + } +} diff --git a/internal/service/ingest_cleanup_test.go b/internal/service/ingest_cleanup_test.go new file mode 100644 index 0000000..5f48fd2 --- /dev/null +++ b/internal/service/ingest_cleanup_test.go @@ -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) + } + } +} diff --git a/internal/service/ingest_service.go b/internal/service/ingest_service.go new file mode 100644 index 0000000..fa172cb --- /dev/null +++ b/internal/service/ingest_service.go @@ -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 +} diff --git a/internal/service/ingest_service_test.go b/internal/service/ingest_service_test.go new file mode 100644 index 0000000..3ed68ea --- /dev/null +++ b/internal/service/ingest_service_test.go @@ -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) + } + } +} diff --git a/internal/service/media_purge.go b/internal/service/media_purge.go new file mode 100644 index 0000000..f9e7151 --- /dev/null +++ b/internal/service/media_purge.go @@ -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) + } +} diff --git a/internal/service/review_service.go b/internal/service/review_service.go new file mode 100644 index 0000000..6a2784b --- /dev/null +++ b/internal/service/review_service.go @@ -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) +}