refactor(publish): 审核侧切到正式表,通过即改状态不再晋升

This commit is contained in:
toom1996
2026-09-23 11:09:12 +08:00
parent e0ce81318d
commit 76626b8372
19 changed files with 423 additions and 775 deletions

View File

@ -132,4 +132,7 @@ type AdminRunway struct {
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
SeasonCode string `json:"season_code"` // SS26 / FW25 / RES26 / PF25 SeasonCode string `json:"season_code"` // SS26 / FW25 / RES26 / PF25
ImageCount uint16 `json:"image_count"` ImageCount uint16 `json:"image_count"`
// Status 审核态(pending / published / rejected),供后台列表展示状态列。
// 后台专用 DTO,不外泄到公开接口。
Status string `json:"status"`
} }

View File

@ -58,6 +58,11 @@ type PublicStreetSnap struct {
Cover string `json:"cover"` Cover string `json:"cover"`
ImageCount uint16 `json:"image_count"` // 该街拍图片总数(卡片 "N+" 角标用) ImageCount uint16 `json:"image_count"` // 该街拍图片总数(卡片 "N+" 角标用)
Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图 Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图
// Status 审核态(pending / published / rejected)。
// 后台街拍列表复用本 DTO 渲染状态列,但公开列表接口**不返回**它:
// json:"-" 保证公开 API 响应结构与既有约定一致。
Status string `json:"-"`
} }
// PublicStreetSnapDetail 对外只读街拍详情:与走秀详情同形(主图 + 细节图)。 // PublicStreetSnapDetail 对外只读街拍详情:与走秀详情同形(主图 + 细节图)。

View File

@ -240,7 +240,8 @@ tbody tr:last-child td { border-bottom: 0; }
} }
.badge.pending { background: #fff4e0; color: #a85b00; } .badge.pending { background: #fff4e0; color: #a85b00; }
.badge.processing { background: #eef2ff; color: #3b4bb5; } .badge.processing { background: #eef2ff; color: #3b4bb5; }
.badge.approved, .badge.done { background: #e6f7ec; color: #1a7f43; } /* published 是单表发布模型的「已通过」(旧值 approved 已被 published 取代)。 */
.badge.published, .badge.approved, .badge.done { background: #e6f7ec; color: #1a7f43; }
.badge.rejected, .badge.failed { background: #fdeaea; color: #c0392b; } .badge.rejected, .badge.failed { background: #fdeaea; color: #c0392b; }
.badge.dup { background: #f3e8ff; color: #6b21a8; } .badge.dup { background: #f3e8ff; color: #6b21a8; }
.badge.free { background: #eef2ff; color: #3b4bb5; } .badge.free { background: #eef2ff; color: #3b4bb5; }

View File

@ -32,7 +32,7 @@ type reviewBatchReq struct {
Items []reviewBatchItem `json:"items"` Items []reviewBatchItem `json:"items"`
} }
// ReviewBatch 批量通过 / 驳回审核草稿。 // ReviewBatch 批量通过 / 驳回审核记录。
// //
// 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]} // 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]}
// 响应: {"ok":2,"failed":[{"id":9,"error":"..."}],"pending":17} // 响应: {"ok":2,"failed":[{"id":9,"error":"..."}],"pending":17}
@ -49,7 +49,7 @@ func (h *BackstageHandler) ReviewBatch(c *gin.Context) {
return return
} }
if len(req.Items) == 0 { if len(req.Items) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "没有选中任何草稿"}) c.JSON(http.StatusBadRequest, gin.H{"error": "没有选中任何记录"})
return return
} }
if len(req.Items) > maxReviewBatch { if len(req.Items) > maxReviewBatch {
@ -99,9 +99,10 @@ type reviewAttachReq struct {
Imgs []uint32 `json:"imgs"` Imgs []uint32 `json:"imgs"`
} }
// ReviewAttachImages 把多张草稿图并入指定主图(街拍审核页「合并选中为一组」的 AJAX 版)。 // ReviewAttachImagesAPI 把多张图并入指定主图(街拍审核页「合并选中为一组」的 AJAX 版)。
// 响应带回最新的图片分组投影,前端据此就地更新标签,无需整页刷新。 // 响应带回最新的图片分组投影,前端据此就地更新标签,无需整页刷新。
func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) { // 加 API 后缀以区别于表单版 ReviewAttachImages:本方法收发 JSON,表单版返回 302。
func (h *BackstageHandler) ReviewAttachImagesAPI(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
if id == 0 { if id == 0 {
@ -121,15 +122,15 @@ func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "请先勾选要合并的图片"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请先勾选要合并的图片"})
return return
} }
if err := h.review.AttachDraftImages(c.Request.Context(), kind, uint32(id), req.Main, req.Imgs); err != nil { if err := h.review.AttachImages(c.Request.Context(), kind, uint32(id), req.Main, req.Imgs); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
h.respondDraftImages(c, kind, uint32(id)) h.respondRecordImages(c, kind, uint32(id))
} }
// ReviewDetachImage 把副图拆出恢复为主图(AJAX 版),响应同 ReviewAttachImages。 // ReviewDetachImageAPI 把副图拆出恢复为主图(AJAX 版),响应同 ReviewAttachImagesAPI。
func (h *BackstageHandler) ReviewDetachImage(c *gin.Context) { func (h *BackstageHandler) ReviewDetachImageAPI(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
imgID := atoiDefault(c.Param("img"), 0) imgID := atoiDefault(c.Param("img"), 0)
@ -137,18 +138,18 @@ func (h *BackstageHandler) ReviewDetachImage(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
return return
} }
if err := h.review.DetachDraftImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil { if err := h.review.DetachImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
h.respondDraftImages(c, kind, uint32(id)) h.respondRecordImages(c, kind, uint32(id))
} }
// respondDraftImages 重新读取草稿并把「图片分组状态」按前端需要的形状返回。 // respondRecordImages 重新读取记录并把「图片分组状态」按前端需要的形状返回。
// //
// 投影只带前端更新标签必需的字段,不重复返回图片 URL(页面上的 <img> 已经有了)。 // 投影只带前端更新标签必需的字段,不重复返回图片 URL(页面上的 <img> 已经有了)。
func (h *BackstageHandler) respondDraftImages(c *gin.Context, kind string, id uint32) { func (h *BackstageHandler) respondRecordImages(c *gin.Context, kind string, id uint32) {
view, err := h.review.DraftDetail(c.Request.Context(), kind, id) view, err := h.review.RecordDetail(c.Request.Context(), kind, id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return

View File

@ -304,9 +304,9 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
renderPage(c, http.StatusOK, "street-snap-detail", gin.H{"Snap": snap}) renderPage(c, http.StatusOK, "street-snap-detail", gin.H{"Snap": snap})
} }
// ── 审核草稿 ─────────────────────────────────────────────────────────────────── // ── 审核记录 ───────────────────────────────────────────────────────────────────
// ReviewList 待审草稿列表:按类型(kind,缺省全部)与状态筛选。 // ReviewList 待审记录列表:按类型(kind,缺省全部)与状态筛选。
// 类型标签由审核注册表生成(见 ReviewService.KindTabs),新增模块自动出现,无需改此分支。 // 类型标签由审核注册表生成(见 ReviewService.KindTabs),新增模块自动出现,无需改此分支。
func (h *BackstageHandler) ReviewList(c *gin.Context) { func (h *BackstageHandler) ReviewList(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
@ -329,7 +329,7 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
if kind != "" && kind != "all" && !registered[kind] { if kind != "" && kind != "all" && !registered[kind] {
kind = "" kind = ""
} }
drafts, total, err := h.review.ListDrafts(c.Request.Context(), kind, status, page, 20) records, total, err := h.review.ListRecords(c.Request.Context(), kind, status, page, 20)
if err != nil { if err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
@ -342,7 +342,7 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
p := pagination.Params{Page: page, PageSize: 20} p := pagination.Params{Page: page, PageSize: 20}
meta := p.NewMeta(total) meta := p.NewMeta(total)
renderPage(c, http.StatusOK, "review-list", gin.H{ renderPage(c, http.StatusOK, "review-list", gin.H{
"Drafts": drafts, "Records": records,
"Total": total, "Total": total,
"Status": status, "Status": status,
"Kind": kind, "Kind": kind,
@ -358,23 +358,23 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
}) })
} }
// ReviewDetail 草稿详情:展示预览 + 可编辑字段 + 通过/驳回操作。 // ReviewDetail 记录详情:展示预览 + 可编辑字段 + 通过/驳回操作。
func (h *BackstageHandler) ReviewDetail(c *gin.Context) { func (h *BackstageHandler) ReviewDetail(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
if id == 0 { if id == 0 {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "草稿不存在: " + c.Param("id")}) renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
return return
} }
draft, err := h.review.DraftDetail(c.Request.Context(), kind, uint32(id)) record, err := h.review.RecordDetail(c.Request.Context(), kind, uint32(id))
if err != nil { if err != nil {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "草稿不存在或已删除: " + c.Param("id")}) renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在或已删除: " + c.Param("id")})
return return
} }
// 有品牌归属的模块(如走秀)解析品牌名;其余模块 BrandID=0 跳过。 // 有品牌归属的模块(如走秀)解析品牌名;其余模块 BrandID=0 跳过。
brandName := "" brandName := ""
if draft.BrandID > 0 { if record.BrandID > 0 {
if b, berr := h.brand.Get(c.Request.Context(), hashid.Encode(draft.BrandID)); berr == nil { if b, berr := h.brand.Get(c.Request.Context(), hashid.Encode(record.BrandID)); berr == nil {
brandName = b.NameEn brandName = b.NameEn
if brandName == "" { if brandName == "" {
brandName = b.NameCn brandName = b.NameCn
@ -382,21 +382,21 @@ func (h *BackstageHandler) ReviewDetail(c *gin.Context) {
} }
} }
renderPage(c, http.StatusOK, "review-detail", gin.H{ renderPage(c, http.StatusOK, "review-detail", gin.H{
"Draft": draft, "Record": record,
"BrandName": brandName, "BrandName": brandName,
}) })
} }
// ReviewApprove 通过:先应用审核人的字段微调,再晋升到正式表。 // ReviewApprove 通过:先应用审核人的字段微调,再把记录状态置为已发布(单表模型:不再晋升重建)。
func (h *BackstageHandler) ReviewApprove(c *gin.Context) { func (h *BackstageHandler) ReviewApprove(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
if id == 0 { if id == 0 {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "草稿不存在: " + c.Param("id")}) renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
return return
} }
fields := draftFormFields(c) fields := reviewFormFields(c)
if err := h.review.UpdateDraft(c.Request.Context(), kind, uint32(id), fields); err != nil { if err := h.review.UpdateRecord(c.Request.Context(), kind, uint32(id), fields); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
@ -413,7 +413,7 @@ func (h *BackstageHandler) ReviewReject(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
if id == 0 { if id == 0 {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "草稿不存在: " + c.Param("id")}) renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
return return
} }
reason := c.PostForm("reason") reason := c.PostForm("reason")
@ -425,9 +425,9 @@ func (h *BackstageHandler) ReviewReject(c *gin.Context) {
c.Redirect(http.StatusFound, reviewListURL(kind)) c.Redirect(http.StatusFound, reviewListURL(kind))
} }
// ReviewDraftImageDelete 软删审核草稿里的单张图片(审核阶段剔除坏图,不删S4文件)。 // ReviewImageDelete 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
// 校验 kind 合法后委派给审核服务,成功后跳回草稿详情页。 // 校验 kind 合法后委派给审核服务,成功后跳回记录详情页。
func (h *BackstageHandler) ReviewDraftImageDelete(c *gin.Context) { func (h *BackstageHandler) ReviewImageDelete(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
imgID := atoiDefault(c.Param("img"), 0) imgID := atoiDefault(c.Param("img"), 0)
@ -435,16 +435,17 @@ func (h *BackstageHandler) ReviewDraftImageDelete(c *gin.Context) {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"}) renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
return return
} }
if err := h.review.DeleteDraftImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil { if err := h.review.DeleteImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
c.Redirect(http.StatusFound, "/admin/reviews/"+kind+"/"+c.Param("id")) c.Redirect(http.StatusFound, "/admin/reviews/"+kind+"/"+c.Param("id"))
} }
// ReviewAttachDraftImages 把勾选的草稿图并入指定主图(街拍审核页「合并选中为一组」)。 // ReviewAttachImages 把勾选的图并入指定主图(街拍审核页「合并选中为一组」的表单版)。
// 主图由表单里的单选 name="main" 指定;待并入的图由多个 checkbox name="img" 提交。 // 主图由表单里的单选 name="main" 指定;待并入的图由多个 checkbox name="img" 提交。
func (h *BackstageHandler) ReviewAttachDraftImages(c *gin.Context) { // (AJAX 版同名的 JSON 接口是 ReviewAttachImagesAPI —— 两者响应形态不同,故不能合并成一个。)
func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
if id == 0 { if id == 0 {
@ -466,15 +467,15 @@ func (h *BackstageHandler) ReviewAttachDraftImages(c *gin.Context) {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请在要作为主图的图片上点选「主图」"}) renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请在要作为主图的图片上点选「主图」"})
return return
} }
if err := h.review.AttachDraftImages(c.Request.Context(), kind, uint32(id), mainID, imgIDs); err != nil { if err := h.review.AttachImages(c.Request.Context(), kind, uint32(id), mainID, imgIDs); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id))) c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id)))
} }
// ReviewDetachDraftImage 把副图拆出恢复为主图。 // ReviewDetachImage 把副图拆出恢复为主图(表单版;AJAX 版见 ReviewDetachImageAPI)。
func (h *BackstageHandler) ReviewDetachDraftImage(c *gin.Context) { func (h *BackstageHandler) ReviewDetachImage(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
imgID := atoiDefault(c.Param("img"), 0) imgID := atoiDefault(c.Param("img"), 0)
@ -482,7 +483,7 @@ func (h *BackstageHandler) ReviewDetachDraftImage(c *gin.Context) {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"}) renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
return return
} }
if err := h.review.DetachDraftImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil { if err := h.review.DetachImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
@ -571,9 +572,9 @@ func (h *BackstageHandler) IngestJobRetry(c *gin.Context) {
c.Redirect(http.StatusFound, "/admin/ingest-jobs") c.Redirect(http.StatusFound, "/admin/ingest-jobs")
} }
// draftFormFields 从表单收集可编辑草稿字段(runway + street 一并收集, // reviewFormFields 从表单收集可编辑字段(runway + street 一并收集,
// 最终白名单在仓储层按 kind 强制,非本 kind 的字段会被忽略)。 // 最终白名单在仓储层按 kind 强制,非本 kind 的字段会被忽略)。
func draftFormFields(c *gin.Context) map[string]any { func reviewFormFields(c *gin.Context) map[string]any {
f := map[string]any{} f := map[string]any{}
if v := c.PostForm("title_en"); v != "" { if v := c.PostForm("title_en"); v != "" {
f["title_en"] = v f["title_en"] = v

View File

@ -74,7 +74,7 @@ func TestBackstageTemplatesRender(t *testing.T) {
"UID": "abc123", "NameEn": "Acne", "NameCn": "", "Account": "", "UID": "abc123", "NameEn": "Acne", "NameCn": "", "Account": "",
"Images": []any{}, "Images": []any{},
// 列表数据 // 列表数据
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Drafts": []any{}, "Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{},
// 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖) // 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖)
"Article": &dto.PublicArticleDetail{UID: "abc123"}, "Article": &dto.PublicArticleDetail{UID: "abc123"},
"Snap": &dto.PublicStreetSnapDetail{UID: "abc123"}, "Snap": &dto.PublicStreetSnapDetail{UID: "abc123"},
@ -100,7 +100,7 @@ func TestBackstageTemplatesRender(t *testing.T) {
for k, v := range perPage[page] { for k, v := range perPage[page] {
data[k] = v data[k] = v
} }
data["Draft"] = &service.DraftDetailView{Kind: kind, ID: 7, Title: "T", Status: "pending"} data["Record"] = &service.RecordDetailView{Kind: kind, ID: 7, Title: "T", Status: "pending"}
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)

View File

@ -1,37 +1,37 @@
{{define "title"}}草稿审核 · 管理后台{{end}} {{define "title"}}草稿审核 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 草稿审核 #{{.Draft.ID}}{{end}} {{define "barLabel"}}管理后台 · 草稿审核 #{{.Record.ID}}{{end}}
{{define "barRight"}}<a href="{{reviewDetailURL .Draft.Kind .Draft.ID}}">返回列表</a> · <a href="/admin/logout">退出</a>{{end}} {{define "barRight"}}<a href="{{reviewDetailURL .Record.Kind .Record.ID}}">返回列表</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "content"}} {{define "content"}}
{{template "bar" .}} {{template "bar" .}}
<div class="main mid"> <div class="main mid">
<p class="muted">类型 <b>{{.Draft.Kind}}</b>{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 <b>{{.Draft.Status}}</b></p> <p class="muted">类型 <b>{{.Record.Kind}}</b>{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 <b>{{.Record.Status}}</b></p>
{{/* 字段微调 + 通过:仍是普通表单提交(无 JS 也能用);键盘 a 会触发同一个表单 */}} {{/* 字段微调 + 通过:仍是普通表单提交(无 JS 也能用);键盘 a 会触发同一个表单 */}}
<form method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/approve"> <form method="post" action="/admin/reviews/{{.Record.Kind}}/{{.Record.ID}}/approve">
{{range .Draft.Fields}}<div><label>{{.Label}}</label>{{if eq .Type "textarea"}}<textarea name="{{.Name}}">{{.Value}}</textarea>{{else}}<input name="{{.Name}}" value="{{.Value}}"{{if eq .Type "number"}} inputmode="numeric"{{end}}>{{end}}</div>{{end}} {{range .Record.Fields}}<div><label>{{.Label}}</label>{{if eq .Type "textarea"}}<textarea name="{{.Name}}">{{.Value}}</textarea>{{else}}<input name="{{.Name}}" value="{{.Value}}"{{if eq .Type "number"}} inputmode="numeric"{{end}}>{{end}}</div>{{end}}
<button type="submit">通过并发布</button> <button type="submit">通过并发布</button>
</form> </form>
{{if eq .Draft.Kind "street"}} {{if eq .Record.Kind "street"}}
<div class="hint"> <div class="hint">
操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。<br> 操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。<br>
快捷键:<b>j</b>/<b>k</b> 上下移动 · <b>x</b> 勾选 · <b>m</b> 设为主图 · <b>d</b> 拆出 · <b>Enter</b> 看大图 · <b>a</b> 通过 快捷键:<b>j</b>/<b>k</b> 上下移动 · <b>x</b> 勾选 · <b>m</b> 设为主图 · <b>d</b> 拆出 · <b>Enter</b> 看大图 · <b>a</b> 通过
<button type="button" class="x" id="hint-dismiss" aria-label="收起提示">✕</button> <button type="button" class="x" id="hint-dismiss" aria-label="收起提示">✕</button>
</div> </div>
<h2>图片(<span id="img-stat">{{len .Draft.Images}} 张 · {{len .Draft.Groups}} 组</span>)</h2> <h2>图片(<span id="img-stat">{{len .Record.Images}} 张 · {{len .Record.Groups}} 组</span>)</h2>
{{/* 合并表单本体在 grid 之前;各格的 radio/checkbox 通过 form="batch" 关联过来 */}} {{/* 合并表单本体在 grid 之前;各格的 radio/checkbox 通过 form="batch" 关联过来 */}}
<form id="batch" method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/images/attach"></form> <form id="batch" method="post" action="/admin/reviews/{{.Record.Kind}}/{{.Record.ID}}/images/attach"></form>
<div class="grid"> <div class="grid">
{{range .Draft.Images}}<div class="cell{{if eq .IsDetail 1}} det{{end}}" data-img-id="{{.ID}}"> {{range .Record.Images}}<div class="cell{{if eq .IsDetail 1}} det{{end}}" data-img-id="{{.ID}}">
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button> <button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
<span class="role">{{if eq .IsDetail 1}}副图 → #{{.ParentImageID}}{{else}}主图{{if .DetailCount}} · {{.DetailCount}} 副图{{end}}{{end}}</span> <span class="role">{{if eq .IsDetail 1}}副图 → #{{.ParentImageID}}{{else}}主图{{if .DetailCount}} · {{.DetailCount}} 副图{{end}}{{end}}</span>
<span class="nm">{{.Name}}</span> <span class="nm">{{.Name}}</span>
<div class="acts"> <div class="acts">
{{if eq .IsDetail 0}}<label class="chk" data-only="main"><input type="radio" form="batch" name="main" value="{{.ID}}"> 主图</label>{{end}} {{if eq .IsDetail 0}}<label class="chk" data-only="main"><input type="radio" form="batch" name="main" value="{{.ID}}"> 主图</label>{{end}}
<label class="chk"><input type="checkbox" form="batch" name="img" value="{{.ID}}"> 选入</label> <label class="chk"><input type="checkbox" form="batch" name="img" value="{{.ID}}"> 选入</label>
{{if eq .IsDetail 1}}<form data-only="detail" method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/detach"><button type="submit">拆出</button></form>{{end}} {{if eq .IsDetail 1}}<form data-only="detail" method="post" action="/admin/reviews/{{$.Record.Kind}}/{{$.Record.ID}}/images/{{.ID}}/detach"><button type="submit">拆出</button></form>{{end}}
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form> <form method="post" action="/admin/reviews/{{$.Record.Kind}}/{{$.Record.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
</div> </div>
</div>{{end}} </div>{{end}}
</div> </div>
@ -41,13 +41,13 @@
<span class="muted">勾选同一人的图,并在其中一张主图上点「主图」</span> <span class="muted">勾选同一人的图,并在其中一张主图上点「主图」</span>
</div> </div>
{{else}} {{else}}
<h2>图片管理({{len .Draft.Images}} 张)</h2> <h2>图片管理({{len .Record.Images}} 张)</h2>
<div class="grid"> <div class="grid">
{{range .Draft.Images}}<div class="cell" data-img-id="{{.ID}}"> {{range .Record.Images}}<div class="cell" data-img-id="{{.ID}}">
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button> <button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
<span class="nm">{{.Name}}</span> <span class="nm">{{.Name}}</span>
<div class="acts"> <div class="acts">
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form> <form method="post" action="/admin/reviews/{{$.Record.Kind}}/{{$.Record.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
</div> </div>
</div>{{end}} </div>{{end}}
</div> </div>

View File

@ -12,7 +12,7 @@
<div class="tabs"> <div class="tabs">
<a href="/admin/reviews{{.ListBase}}" class="{{if eq .Status ""}}on{{end}}">全部</a> <a href="/admin/reviews{{.ListBase}}" class="{{if eq .Status ""}}on{{end}}">全部</a>
<a href="/admin/reviews{{.ListBase}}?status=pending" class="{{if eq .Status "pending"}}on{{end}}">待审核</a> <a href="/admin/reviews{{.ListBase}}?status=pending" class="{{if eq .Status "pending"}}on{{end}}">待审核</a>
<a href="/admin/reviews{{.ListBase}}?status=approved" class="{{if eq .Status "approved"}}on{{end}}">已通过</a> <a href="/admin/reviews{{.ListBase}}?status=published" class="{{if eq .Status "published"}}on{{end}}">已通过</a>
<a href="/admin/reviews{{.ListBase}}?status=rejected" class="{{if eq .Status "rejected"}}on{{end}}">已驳回</a> <a href="/admin/reviews{{.ListBase}}?status=rejected" class="{{if eq .Status "rejected"}}on{{end}}">已驳回</a>
</div> </div>
@ -24,7 +24,7 @@
<th>ID</th><th>类型</th><th>标题</th><th>副标题</th><th>图数</th><th>状态</th><th>操作</th> <th>ID</th><th>类型</th><th>标题</th><th>副标题</th><th>图数</th><th>状态</th><th>操作</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{{range .Drafts}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}"> {{range .Records}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}">
<td><input type="checkbox" class="row-pick" aria-label="选择这条"></td> <td><input type="checkbox" class="row-pick" aria-label="选择这条"></td>
<td><code>{{.ID}}</code></td> <td><code>{{.ID}}</code></td>
<td>{{if eq .Kind "street"}}<span class="kind street">{{.Kind}}</span>{{else}}<span class="kind">{{.Kind}}</span>{{end}}</td> <td>{{if eq .Kind "street"}}<span class="kind street">{{.Kind}}</span>{{else}}<span class="kind">{{.Kind}}</span>{{end}}</td>
@ -32,7 +32,7 @@
<td class="muted">{{.Subtitle}}</td> <td class="muted">{{.Subtitle}}</td>
<td>{{.ImageCount}}</td> <td>{{.ImageCount}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td> <td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td><a class="view" href="/admin/reviews/{{.Kind}}/{{.ID}}">查看/审核</a>{{if eq .Status "pending"}} · <button type="button" class="btn" data-quick-approve title="按当前内容直接晋升到正式表">通过</button>{{end}}</td> <td><a class="view" href="/admin/reviews/{{.Kind}}/{{.ID}}">查看/审核</a>{{if eq .Status "pending"}} · <button type="button" class="btn" data-quick-approve title="按当前内容直接发布">通过</button>{{end}}</td>
</tr>{{end}} </tr>{{end}}
</tbody> </tbody>
</table> </table>

View File

@ -29,9 +29,9 @@
<a href="/admin/runways">清除</a> <a href="/admin/runways">清除</a>
</form> </form>
<table> <table>
<thead><tr><th>编码 ID</th><th>品牌</th><th>标题</th><th>系列</th><th>季节</th><th>年份</th><th>季节码</th><th>图数</th><th></th></tr></thead> <thead><tr><th>编码 ID</th><th>品牌</th><th>标题</th><th>系列</th><th>季节</th><th>年份</th><th>季节码</th><th>图数</th><th>状态</th><th></th></tr></thead>
<tbody> <tbody>
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.BrandName}}</td><td>{{.Title}}</td><td>{{.CollectionType}}</td><td>{{.Season}}</td><td>{{.Year}}</td><td>{{.SeasonCode}}</td><td>{{.ImageCount}}</td><td><a class="view" href="/admin/runways/{{.UID}}">查看</a></td></tr>{{end}} {{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.BrandName}}</td><td>{{.Title}}</td><td>{{.CollectionType}}</td><td>{{.Season}}</td><td>{{.Year}}</td><td>{{.SeasonCode}}</td><td>{{.ImageCount}}</td><td><span class="badge {{.Status}}">{{.Status}}</span></td><td><a class="view" href="/admin/runways/{{.UID}}">查看</a></td></tr>{{end}}
</tbody> </tbody>
</table> </table>
{{template "pager" .Pager}} {{template "pager" .Pager}}

View File

@ -17,9 +17,9 @@
<a href="/admin/street-snaps">清除</a> <a href="/admin/street-snaps">清除</a>
</form> </form>
<table> <table>
<thead><tr><th>编码 ID</th><th>标题</th><th>图片数</th><th></th></tr></thead> <thead><tr><th>编码 ID</th><th>标题</th><th>图片数</th><th>状态</th><th></th></tr></thead>
<tbody> <tbody>
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.Title}}</td><td>{{.ImageCount}}</td><td><a class="view" href="/admin/street-snaps/{{.UID}}">查看</a></td></tr>{{end}} {{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.Title}}</td><td>{{.ImageCount}}</td><td><span class="badge {{.Status}}">{{.Status}}</span></td><td><a class="view" href="/admin/street-snaps/{{.UID}}">查看</a></td></tr>{{end}}
</tbody> </tbody>
</table> </table>
{{template "pager" .Pager}} {{template "pager" .Pager}}

View File

@ -67,11 +67,12 @@ const articleDetailColumns = `brand_runways.id, brand_runways.title_en, brand_ru
brand_runways.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` brand_runways.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
// articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year / // articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year /
// collection_type / season_code 等管理字段(前端列表不展示这些)。 // collection_type / season_code / status 等管理字段(前端列表不展示这些)。
const articleAdminColumns = `brand_runways.id, brand_runways.brand_id, const articleAdminColumns = `brand_runways.id, brand_runways.brand_id,
brand_runways.title_en, brand_runways.title_cn, brand_runways.title_en, brand_runways.title_cn,
brand_runways.cover, brand_runways.image_count, brand_runways.year, brand_runways.cover, brand_runways.image_count, brand_runways.year,
brand_runways.collection_type, brand_runways.season, brand_runways.season_code, brand_runways.collection_type, brand_runways.season, brand_runways.season_code,
brand_runways.status,
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
// brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。 // brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。

View File

@ -11,49 +11,44 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// ReviewRepository 审核草稿的数据访问接口。 // ReviewRepository 审核数据访问接口。
// //
// 草稿由爬虫 worker 写入(见 IngestRepository),本接口负责「读 + 晋升」: // 单表发布模型下,审核对象就是正式表里的记录本身(不再有草稿表):
// 列出待审草稿、查看详情、审核前微调字段、通过时晋升到正式表、驳回时标记状态。 // 列出待审记录、查看详情、审核前微调字段、通过(置 published)/ 驳回(置 rejected)。
type ReviewRepository interface { type ReviewRepository interface {
// ListDrafts 分页列出草稿;status 为空表示全部,否则按状态过滤。 // ListRecords 分页列出记录;status 为空表示全部,否则按状态过滤。排序:pending 优先,其次 id 倒序。
// 排序:pending 优先,其次按 id 倒序(最新在前)。 ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error)
ListDrafts(ctx context.Context, status string, page, size int) ([]model.BrandRunwayDraft, int64, error) // RecordDetail 查单条走秀记录(不含图片)。
// DraftDetail 查单条草稿(不含图片)。 RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error)
DraftDetail(ctx context.Context, id uint32) (*model.BrandRunwayDraft, error) // RecordImages 查走秀记录的全部未删图片,按排序值升序。
// DraftImages 查草稿的全部图片,按排序值升序。 RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error)
DraftImages(ctx context.Context, draftID uint32) ([]model.BrandRunwayDraftImage, error) // UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。
// UpdateDraft 审核前微调草稿字段(白名单,杜绝任意列注入)。 UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error // SetRecordStatus 设置状态(通过/驳回)与审核人/驳回理由。
// SetDraftStatus 设置草稿状态(通过/驳回)与审核人/驳回理由。 SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
// SaveRunwayFromDraft 晋升:按实体键(brand_id + season_code + collection_type)幂等 upsert 到 brand_runways 正式表并替换图片,返回正式表主键。 // 街拍镜像方法(对象为 street_snaps / street_snap_images)。
SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error) ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error)
// 街拍草稿镜像方法(与 runway 同构,但对象为 street_snap_drafts / street_snaps)。 StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error)
ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error) StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error)
StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error) UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error
StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
// SoftDeleteRunwayDraftImage 软删单张走秀草稿图片(审核阶段剔除坏图),并同步 image_count。
SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error // SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并同步 image_count。
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。 SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error
SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error // SoftDeleteStreetImage 软删单张街拍图片,并同步 image_count。
// AttachStreetDraftImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。 SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error
// mainImgID 必须属于同一草稿、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。 // AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
// 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID —— // mainImgID 必须属于同一记录、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。
// 避免出现「副图的副图」破坏不变量(见计划「实现裁决」第 1 条)。 // 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID —— 避免「副图的副图」。
// 幂等:已是 mainImgID 副图的行重复并入不报错。 // 幂等:已是 mainImgID 副图的行重复并入不报错。
AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error
// DetachStreetDraftImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。 // DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。
// 对主图调用是幂等成功(无副作用)。 DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error
DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error // GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
// GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。 GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error)
GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error) // CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。
UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error CountByStatus(ctx context.Context, status string, dest any) (int64, error)
SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error)
// CountDraftPending 统计某草稿表(dest 传表对应的零值指针,如 &model.BrandRunwayDraft{})中指定状态的草稿数。
// 供各 ReviewModule 复用,新增模块无需再单独写计数方法。
CountDraftPending(ctx context.Context, status string, dest any) (int64, error)
} }
type reviewRepository struct { type reviewRepository struct {
@ -65,14 +60,14 @@ func NewReviewRepository(db *gorm.DB) ReviewRepository {
return &reviewRepository{db: db} return &reviewRepository{db: db}
} }
func (r *reviewRepository) ListDrafts(ctx context.Context, status string, page, size int) ([]model.BrandRunwayDraft, int64, error) { func (r *reviewRepository) ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error) {
if page < 1 { if page < 1 {
page = 1 page = 1
} }
if size < 1 || size > 100 { if size < 1 || size > 100 {
size = 20 size = 20
} }
q := r.db.WithContext(ctx).Model(&model.BrandRunwayDraft{}).Where("is_deleted = 0") q := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Where("is_deleted = 0")
if status != "" { if status != "" {
q = q.Where("status = ?", status) q = q.Where("status = ?", status)
} }
@ -81,11 +76,11 @@ func (r *reviewRepository) ListDrafts(ctx context.Context, status string, page,
return nil, 0, err return nil, 0, err
} }
if total == 0 { if total == 0 {
return []model.BrandRunwayDraft{}, 0, nil return []model.BrandRunway{}, 0, nil
} }
var rows []model.BrandRunwayDraft var rows []model.BrandRunway
if err := q. if err := q.
Order("CASE status WHEN '" + model.DraftStatusPending + "' THEN 0 ELSE 1 END ASC, id DESC"). Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
Offset((page - 1) * size). Offset((page - 1) * size).
Limit(size). Limit(size).
Find(&rows).Error; err != nil { Find(&rows).Error; err != nil {
@ -94,8 +89,8 @@ func (r *reviewRepository) ListDrafts(ctx context.Context, status string, page,
return rows, total, nil return rows, total, nil
} }
func (r *reviewRepository) DraftDetail(ctx context.Context, id uint32) (*model.BrandRunwayDraft, error) { func (r *reviewRepository) RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error) {
var d model.BrandRunwayDraft var d model.BrandRunway
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil { if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound return nil, ErrNotFound
@ -105,17 +100,17 @@ func (r *reviewRepository) DraftDetail(ctx context.Context, id uint32) (*model.B
return &d, nil return &d, nil
} }
func (r *reviewRepository) DraftImages(ctx context.Context, draftID uint32) ([]model.BrandRunwayDraftImage, error) { func (r *reviewRepository) RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error) {
var imgs []model.BrandRunwayDraftImage var imgs []model.BrandRunwayImage
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Where("draft_id = ? AND is_deleted = 0", draftID). Where("runway_id = ? AND is_deleted = 0", recordID).
Order("sort_order ASC, id ASC"). Order("sort_order ASC, id ASC").
Find(&imgs).Error Find(&imgs).Error
return imgs, err return imgs, err
} }
// draftEditable 审核前可微调的字段白名单(键即列名)。 // recordEditable 审核前可微调的字段白名单(键即列名)。
var draftEditable = map[string]bool{ var recordEditable = map[string]bool{
"title_en": true, "title_en": true,
"title_cn": true, "title_cn": true,
"description_en": true, "description_en": true,
@ -126,11 +121,11 @@ var draftEditable = map[string]bool{
"season_code": true, "season_code": true,
} }
// UpdateDraft 只更新白名单内的字段,其余忽略,杜绝任意列注入。 // UpdateRecord 只更新白名单内的字段,其余忽略,杜绝任意列注入。
func (r *reviewRepository) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error { func (r *reviewRepository) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
clean := map[string]any{} clean := map[string]any{}
for k, v := range fields { for k, v := range fields {
if draftEditable[k] { if recordEditable[k] {
clean[k] = v clean[k] = v
} }
} }
@ -139,14 +134,14 @@ func (r *reviewRepository) UpdateDraft(ctx context.Context, id uint32, fields ma
} }
clean["updated_at"] = uint32(time.Now().Unix()) clean["updated_at"] = uint32(time.Now().Unix())
return r.db.WithContext(ctx). return r.db.WithContext(ctx).
Model(&model.BrandRunwayDraft{}). Model(&model.BrandRunway{}).
Where("id = ?", id). Where("id = ?", id).
Updates(clean).Error Updates(clean).Error
} }
func (r *reviewRepository) SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error { func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
return r.db.WithContext(ctx). return r.db.WithContext(ctx).
Model(&model.BrandRunwayDraft{}). Model(&model.BrandRunway{}).
Where("id = ?", id). Where("id = ?", id).
Updates(map[string]any{ Updates(map[string]any{
"status": status, "status": status,
@ -156,132 +151,8 @@ func (r *reviewRepository) SetDraftStatus(ctx context.Context, id uint32, status
}).Error }).Error
} }
// SaveRunwayFromDraft 晋升草稿到正式表: // CountByStatus 统计传入表(dest 零值指针)中指定状态的行数;供各 ReviewModule 复用。
// 1. 按实体键(brand_id + season_code + collection_type)查找已存在的正式走秀(幂等:重审同一草稿只更新,不重复建行); func (r *reviewRepository) CountByStatus(ctx context.Context, status string, dest any) (int64, error) {
// 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 去重,
// S4 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)
// image_count 仅计主图(is_detail=0):对外列表卡片「N 张」与详情默认画廊都只展示主图,
// 细节图作为主图的附属按需展开,不计入主图数。
mainCount := 0
for _, im := range imgs {
if im.IsDetail == 0 {
mainCount++
}
}
now := uint32(time.Now().Unix())
var runwayID uint32
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1) 按实体键查找既有正式走秀
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,
"image_count": uint16(mainCount),
"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,
ImageCount: uint16(mainCount),
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),
LookIndex: im.LookIndex,
IsDetail: im.IsDetail,
Phash: im.Phash,
IsDuplicate: im.IsDuplicate,
DupOf: im.DupOf,
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
}
// CountDraftPending 统计传入表(dest 零值指针)中指定状态的草稿数;供各 ReviewModule 复用。
func (r *reviewRepository) CountDraftPending(ctx context.Context, status string, dest any) (int64, error) {
var n int64 var n int64
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
Model(dest). Model(dest).
@ -292,16 +163,16 @@ func (r *reviewRepository) CountDraftPending(ctx context.Context, status string,
return n, nil return n, nil
} }
// ---- 街拍草稿镜像实现(与 runway 同构) ---- // ---- 街拍镜像实现(与 runway 同构) ----
func (r *reviewRepository) ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error) { func (r *reviewRepository) ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error) {
if page < 1 { if page < 1 {
page = 1 page = 1
} }
if size < 1 || size > 100 { if size < 1 || size > 100 {
size = 20 size = 20
} }
q := r.db.WithContext(ctx).Model(&model.StreetSnapDraft{}).Where("is_deleted = 0") q := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Where("is_deleted = 0")
if status != "" { if status != "" {
q = q.Where("status = ?", status) q = q.Where("status = ?", status)
} }
@ -310,11 +181,11 @@ func (r *reviewRepository) ListStreetDrafts(ctx context.Context, status string,
return nil, 0, err return nil, 0, err
} }
if total == 0 { if total == 0 {
return []model.StreetSnapDraft{}, 0, nil return []model.StreetSnap{}, 0, nil
} }
var rows []model.StreetSnapDraft var rows []model.StreetSnap
if err := q. if err := q.
Order("CASE status WHEN '" + model.DraftStatusPending + "' THEN 0 ELSE 1 END ASC, id DESC"). Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
Offset((page - 1) * size). Offset((page - 1) * size).
Limit(size). Limit(size).
Find(&rows).Error; err != nil { Find(&rows).Error; err != nil {
@ -323,8 +194,8 @@ func (r *reviewRepository) ListStreetDrafts(ctx context.Context, status string,
return rows, total, nil return rows, total, nil
} }
func (r *reviewRepository) StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error) { func (r *reviewRepository) StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error) {
var d model.StreetSnapDraft var d model.StreetSnap
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil { if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound return nil, ErrNotFound
@ -334,26 +205,26 @@ func (r *reviewRepository) StreetDraftDetail(ctx context.Context, id uint32) (*m
return &d, nil return &d, nil
} }
func (r *reviewRepository) StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error) { func (r *reviewRepository) StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error) {
var imgs []model.StreetSnapDraftImage var imgs []model.StreetSnapImage
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Where("draft_id = ? AND is_deleted = 0", draftID). Where("snap_id = ? AND is_deleted = 0", recordID).
Order("sort_order ASC, id ASC"). Order("sort_order ASC, id ASC").
Find(&imgs).Error Find(&imgs).Error
return imgs, err return imgs, err
} }
// streetDraftEditable 街拍草稿可微调字段白名单(title / year / city)。 // streetRecordEditable 街拍记录可微调字段白名单(title / year / city)。
var streetDraftEditable = map[string]bool{ var streetRecordEditable = map[string]bool{
"title": true, "title": true,
"year": true, "year": true,
"city": true, "city": true,
} }
func (r *reviewRepository) UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error { func (r *reviewRepository) UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error {
clean := map[string]any{} clean := map[string]any{}
for k, v := range fields { for k, v := range fields {
if streetDraftEditable[k] { if streetRecordEditable[k] {
clean[k] = v clean[k] = v
} }
} }
@ -362,14 +233,14 @@ func (r *reviewRepository) UpdateStreetDraft(ctx context.Context, id uint32, fie
} }
clean["updated_at"] = uint32(time.Now().Unix()) clean["updated_at"] = uint32(time.Now().Unix())
return r.db.WithContext(ctx). return r.db.WithContext(ctx).
Model(&model.StreetSnapDraft{}). Model(&model.StreetSnap{}).
Where("id = ?", id). Where("id = ?", id).
Updates(clean).Error Updates(clean).Error
} }
func (r *reviewRepository) SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error { func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
return r.db.WithContext(ctx). return r.db.WithContext(ctx).
Model(&model.StreetSnapDraft{}). Model(&model.StreetSnap{}).
Where("id = ?", id). Where("id = ?", id).
Updates(map[string]any{ Updates(map[string]any{
"status": status, "status": status,
@ -379,266 +250,40 @@ func (r *reviewRepository) SetStreetDraftStatus(ctx context.Context, id uint32,
}).Error }).Error
} }
// SaveStreetSnapFromDraft 晋升街拍草稿到正式表 street_snaps(按实体键 city+year 幂等 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()) // SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并同步记录 image_count。
var snapID uint32 // 不删S4文件(与正式表既有删除行为一致:仅 DB 标记,避免误删被引用文件)。
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { func (r *reviewRepository) SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) 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,
"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,
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 {
// 第一遍:插入主图(is_detail=0),并记下「草稿行 id → 新正式行 id」的映射。
//
// 必须两遍插入:副图的 parent_image_id 存的是**草稿表**的行 id,
// 而这里插入的正式行拿到的是全新 id。若先插副图,父引用就会指向草稿表的旧 id,
// 详情页折叠结构随之错乱(见计划「任务 2」标题下的说明)。
newIDByDraftID := make(map[uint32]uint32, len(imgs))
mains := make([]model.StreetSnapImage, 0, len(imgs))
for _, im := range imgs {
if im.IsDetail == 1 {
continue
}
mains = append(mains, model.StreetSnapImage{
SnapID: snapID,
Image: im.Image,
Name: im.Name,
SortOrder: uint32(len(mains) + 1),
IsDetail: 0,
Phash: im.Phash,
IsDuplicate: im.IsDuplicate,
DupOf: im.DupOf,
CreatedAt: now,
UpdatedAt: now,
})
}
for i := range mains {
if cErr := tx.Create(&mains[i]).Error; cErr != nil {
return cErr
}
}
mainIdx := 0
for _, im := range imgs {
if im.IsDetail == 1 {
continue
}
newIDByDraftID[im.ID] = mains[mainIdx].ID
mainIdx++
}
// 第二遍:插入副图,把父引用改写成新主图行 id。
details := make([]model.StreetSnapImage, 0, len(imgs)-len(mains))
for _, im := range imgs {
if im.IsDetail != 1 {
continue
}
newParent, ok := newIDByDraftID[im.ParentImageID]
row := model.StreetSnapImage{
SnapID: snapID,
Image: im.Image,
Name: im.Name,
SortOrder: uint32(len(mains) + len(details) + 1),
IsDetail: 1,
Phash: im.Phash,
IsDuplicate: im.IsDuplicate,
DupOf: im.DupOf,
CreatedAt: now,
UpdatedAt: now,
}
if ok {
row.ParentImageID = newParent
} else {
row.IsDetail = 0 // 父行缺失:按主图渲染,与读侧容错口径一致
}
details = append(details, row)
}
if len(details) > 0 {
if cErr := tx.Create(&details).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). 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). Model(&model.BrandRunwayImage{}).
Find(&drafts).Error; err != nil { Where("id = ? AND runway_id = ? AND is_deleted = 0", imgID, recordID).
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 去重合并(S4 key 已是 sha1,同图 URL 必相同)。当前草稿图优先。
func unionRunwayDraftImages(a, b []model.BrandRunwayDraftImage) []model.BrandRunwayDraftImage {
seen := make(map[string]struct{}, len(a)+len(b))
out := make([]model.BrandRunwayDraftImage, 0, len(a)+len(b))
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。
// 不删S4文件(与正式表 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 { Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err return err
} }
return r.db.WithContext(ctx). return r.db.WithContext(ctx).
Exec("UPDATE brand_runway_drafts SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error Exec("UPDATE brand_runways SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", recordID).Error
} }
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。 // SoftDeleteStreetImage 软删单张街拍图片,并同步记录 image_count。
func (r *reviewRepository) SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error { func (r *reviewRepository) SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error {
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
Model(&model.StreetSnapDraftImage{}). Model(&model.StreetSnapImage{}).
Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID). Where("id = ? AND snap_id = ? AND is_deleted = 0", imgID, recordID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err return err
} }
return r.db.WithContext(ctx). return r.db.WithContext(ctx).
Exec("UPDATE street_snap_drafts SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error Exec("UPDATE street_snaps SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", recordID).Error
} }
// GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。 // GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
// kind 决定查哪张草稿图片表(runway / street);未知 kind 返回 ErrNotFound。 // kind 决定查哪张图片表(runway / street);未知 kind 返回 ErrNotFound。
func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error) { func (r *reviewRepository) GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error) {
switch kind { switch kind {
case dto.IngestKindRunway: case dto.IngestKindRunway:
var im model.BrandRunwayDraftImage var im model.BrandRunwayImage
err := r.db.WithContext(ctx).Where("id = ? AND draft_id = ?", imgID, draftID).First(&im).Error err := r.db.WithContext(ctx).Where("id = ? AND runway_id = ?", imgID, recordID).First(&im).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil return "", nil
} }
@ -647,8 +292,8 @@ func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, dr
} }
return im.Image, nil return im.Image, nil
case dto.IngestKindStreet: case dto.IngestKindStreet:
var im model.StreetSnapDraftImage var im model.StreetSnapImage
err := r.db.WithContext(ctx).Where("id = ? AND draft_id = ?", imgID, draftID).First(&im).Error err := r.db.WithContext(ctx).Where("id = ? AND snap_id = ?", imgID, recordID).First(&im).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil return "", nil
} }
@ -661,13 +306,13 @@ func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, dr
} }
} }
// ── 街拍草稿图片:主副图分组 ────────────────────────────────────────────── // ── 街拍主副图分组 ──────────────────────────────────────────────────────────
// AttachStreetDraftImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。 // AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
// //
// 事务内三步:1) 校验主图存在、未软删、本身是主图;2) 先「改挂孙辈」——被并入图原有的副图 // 事务内三步:1) 校验主图存在、未软删、本身是主图;2) 先「改挂孙辈」——被并入图原有的副图
// 改挂到新主图(避免副图的副图);3) 再把被并入图自身标为副图。第 2 步必须在第 3 步之前。 // 改挂到新主图(避免副图的副图);3) 再把被并入图自身标为副图。第 2 步必须在第 3 步之前。
func (r *reviewRepository) AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error { func (r *reviewRepository) AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error {
ids := make([]uint32, 0, len(imgIDs)) ids := make([]uint32, 0, len(imgIDs))
for _, id := range imgIDs { for _, id := range imgIDs {
if id != 0 && id != mainImgID { if id != 0 && id != mainImgID {
@ -684,8 +329,8 @@ func (r *reviewRepository) AttachStreetDraftImages(ctx context.Context, draftID,
} }
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var main model.StreetSnapDraftImage var main model.StreetSnapImage
if err := tx.Where("id = ? AND draft_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, draftID). if err := tx.Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, snapID).
First(&main).Error; err != nil { First(&main).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrInvalidImageGroup return ErrInvalidImageGroup
@ -694,23 +339,23 @@ func (r *reviewRepository) AttachStreetDraftImages(ctx context.Context, draftID,
} }
now := uint32(time.Now().Unix()) now := uint32(time.Now().Unix())
if err := tx.Model(&model.StreetSnapDraftImage{}). if err := tx.Model(&model.StreetSnapImage{}).
Where("draft_id = ? AND is_deleted = 0 AND parent_image_id IN ?", draftID, ids). Where("snap_id = ? AND is_deleted = 0 AND parent_image_id IN ?", snapID, ids).
Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil { Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil {
return err return err
} }
return tx.Model(&model.StreetSnapDraftImage{}). return tx.Model(&model.StreetSnapImage{}).
Where("draft_id = ? AND is_deleted = 0 AND id IN ?", draftID, ids). Where("snap_id = ? AND is_deleted = 0 AND id IN ?", snapID, ids).
Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error
}) })
} }
// DetachStreetDraftImage 把副图恢复为主图。对主图调用为幂等成功。 // DetachStreetSnapImage 把副图恢复为主图。对主图调用为幂等成功。
// 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。 // 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。
func (r *reviewRepository) DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error { func (r *reviewRepository) DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error {
return r.db.WithContext(ctx). return r.db.WithContext(ctx).
Model(&model.StreetSnapDraftImage{}). Model(&model.StreetSnapImage{}).
Where("id = ? AND draft_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, draftID). Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, snapID).
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}). Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
Error Error
} }

View File

@ -1,6 +1,9 @@
//go:build integration //go:build integration
// 集成测试:街拍草稿主副图分组需要真实 PostgreSQL。 // 集成测试:街拍主副图分组需要真实 PostgreSQL。
//
// 单表发布模型下操作对象是正式表本身(street_snaps / street_snap_images),
// 不再有草稿表;审核通过只改状态、不重建图片,故本文件不再覆盖「晋升重建父引用」。
// //
// 运行方式(先起库并确保结构已就绪,见 README「数据库」一节): // 运行方式(先起库并确保结构已就绪,见 README「数据库」一节):
// //
@ -13,61 +16,74 @@ package repository
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"testing" "testing"
"fashionapi/internal/model" "fashionapi/internal/model"
) )
// seedStreetDraft 插入一条街拍草稿并返回其 id。 // mainDetailSeedSeq 让每次 seed 出来的实体键(city + year)互不相同。
func seedStreetDraft(t *testing.T, repo ReviewRepository) uint32 { // 正式表上有部分唯一索引 uq_ss_entity (city, year) WHERE is_deleted = 0,
// 若测试沿用同一 city/year,第二条记录会被唯一索引拒绝。
var mainDetailSeedSeq int
// seedMainDetailSnap 插入一条街拍正式记录(status=pending)并返回其 id。
// 命名加 MainDetail 前缀以避免与 publish_status_integration_test.go 的 seedStreetSnap 冲突。
func seedMainDetailSnap(t *testing.T, repo ReviewRepository) uint32 {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
db := repo.(*reviewRepository).db db := repo.(*reviewRepository).db
d := model.StreetSnapDraft{Title: "主副图测试", Year: 2026, City: "TestCity", Status: model.DraftStatusPending} mainDetailSeedSeq++
if err := db.WithContext(ctx).Create(&d).Error; err != nil { snap := model.StreetSnap{
t.Fatalf("插入草稿失败: %v", err) Title: "主副图测试",
Year: 2026,
City: fmt.Sprintf("TestCity-%d", mainDetailSeedSeq),
Status: model.StatusPending,
}
if err := db.WithContext(ctx).Create(&snap).Error; err != nil {
t.Fatalf("插入街拍记录失败: %v", err)
} }
t.Cleanup(func() { t.Cleanup(func() {
db.Exec("DELETE FROM street_snap_draft_images WHERE draft_id = ?", d.ID) db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snap.ID)
db.Exec("DELETE FROM street_snap_drafts WHERE id = ?", d.ID) db.Exec("DELETE FROM street_snaps WHERE id = ?", snap.ID)
}) })
return d.ID return snap.ID
} }
// seedStreetDraftImage 插入一张草稿图并返回其行 id。 // seedMainDetailImage 插入一张正式街拍图并返回其行 id。
func seedStreetDraftImage(t *testing.T, repo ReviewRepository, draftID uint32, name string, order uint32) uint32 { func seedMainDetailImage(t *testing.T, repo ReviewRepository, snapID uint32, name string, order uint32) uint32 {
t.Helper() t.Helper()
img := model.StreetSnapDraftImage{DraftID: draftID, Image: name + ".jpg", Name: name, SortOrder: order} img := model.StreetSnapImage{SnapID: snapID, Image: name + ".jpg", Name: name, SortOrder: order}
if err := repo.(*reviewRepository).db.WithContext(context.Background()).Create(&img).Error; err != nil { if err := repo.(*reviewRepository).db.WithContext(context.Background()).Create(&img).Error; err != nil {
t.Fatalf("插入草稿图失败: %v", err) t.Fatalf("插入街拍图失败: %v", err)
} }
return img.ID return img.ID
} }
// reloadStreetImages 取草稿的全部图(按 sort_order, id 升序),便于断言。 // reloadSnapImages 取记录的全部图(按 sort_order, id 升序),便于断言。
func reloadStreetImages(t *testing.T, repo ReviewRepository, draftID uint32) []model.StreetSnapDraftImage { func reloadSnapImages(t *testing.T, repo ReviewRepository, snapID uint32) []model.StreetSnapImage {
t.Helper() t.Helper()
imgs, err := repo.StreetDraftImages(context.Background(), draftID) imgs, err := repo.StreetRecordImages(context.Background(), snapID)
if err != nil { if err != nil {
t.Fatalf("读草稿图失败: %v", err) t.Fatalf("读街拍图失败: %v", err)
} }
return imgs return imgs
} }
// TestStreetMainDetail 覆盖批量并入 / 并入上一张 / 拆出 / 跨草稿拦截四条主线。 // TestStreetMainDetail 覆盖批量并入 / 拆出 / 跨记录拦截三条主线。
func TestStreetMainDetail(t *testing.T) { func TestStreetMainDetail(t *testing.T) {
repo := NewReviewRepository(testDB(t)) repo := NewReviewRepository(testDB(t))
ctx := context.Background() ctx := context.Background()
draftID := seedStreetDraft(t, repo) snapID := seedMainDetailSnap(t, repo)
m1 := seedStreetDraftImage(t, repo, draftID, "a", 1) m1 := seedMainDetailImage(t, repo, snapID, "a", 1)
d2 := seedStreetDraftImage(t, repo, draftID, "b", 2) d2 := seedMainDetailImage(t, repo, snapID, "b", 2)
d3 := seedStreetDraftImage(t, repo, draftID, "c", 3) d3 := seedMainDetailImage(t, repo, snapID, "c", 3)
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil { if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{d2, d3}); err != nil {
t.Fatalf("并入失败: %v", err) t.Fatalf("并入失败: %v", err)
} }
for _, im := range reloadStreetImages(t, repo, draftID) { for _, im := range reloadSnapImages(t, repo, snapID) {
switch im.ID { switch im.ID {
case m1: case m1:
if im.IsDetail != 0 || im.ParentImageID != 0 { if im.IsDetail != 0 || im.ParentImageID != 0 {
@ -81,40 +97,40 @@ func TestStreetMainDetail(t *testing.T) {
} }
// 幂等:重复并入同一批不报错,结果不变。 // 幂等:重复并入同一批不报错,结果不变。
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil { if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{d2, d3}); err != nil {
t.Fatalf("重复并入应幂等,实际报错: %v", err) t.Fatalf("重复并入应幂等,实际报错: %v", err)
} }
// 传入主图自身:调用方过滤掉的语义由仓储兜住(id <> main 条件),不报错也不改动主图。 // 传入主图自身:调用方过滤掉的语义由仓储兜住(id <> main 条件),不报错也不改动主图。
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{m1}); err != nil { if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{m1}); err != nil {
t.Fatalf("并入集合含主图自身应被忽略而非报错: %v", err) t.Fatalf("并入集合含主图自身应被忽略而非报错: %v", err)
} }
for _, im := range reloadStreetImages(t, repo, draftID) { for _, im := range reloadSnapImages(t, repo, snapID) {
if im.ID == m1 && (im.IsDetail != 0 || im.ParentImageID != 0) { if im.ID == m1 && (im.IsDetail != 0 || im.ParentImageID != 0) {
t.Fatalf("主图被自身并入后不应变化") t.Fatalf("主图被自身并入后不应变化")
} }
} }
// 拆出:把 d3 恢复为主图。 // 拆出:把 d3 恢复为主图。
if err := repo.DetachStreetDraftImage(ctx, draftID, d3); err != nil { if err := repo.DetachStreetSnapImage(ctx, snapID, d3); err != nil {
t.Fatalf("拆出失败: %v", err) t.Fatalf("拆出失败: %v", err)
} }
for _, im := range reloadStreetImages(t, repo, draftID) { for _, im := range reloadSnapImages(t, repo, snapID) {
if im.ID == d3 && (im.IsDetail != 0 || im.ParentImageID != 0) { if im.ID == d3 && (im.IsDetail != 0 || im.ParentImageID != 0) {
t.Fatalf("拆出后应为 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID) t.Fatalf("拆出后应为 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID)
} }
} }
if err := repo.DetachStreetDraftImage(ctx, draftID, m1); err != nil { // 对主图幂等成功 if err := repo.DetachStreetSnapImage(ctx, snapID, m1); err != nil { // 对主图幂等成功
t.Fatalf("对主图拆出应幂等成功: %v", err) t.Fatalf("对主图拆出应幂等成功: %v", err)
} }
// 跨草稿拦截:空集合 + 跨草稿主图。 // 跨记录拦截:空集合 + 跨记录主图。
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, nil); !errors.Is(err, ErrInvalidImageGroup) { if err := repo.AttachStreetSnapImages(ctx, snapID, m1, nil); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err) t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err)
} }
otherMain := seedStreetDraftImage(t, repo, seedStreetDraft(t, repo), "x", 1) otherMain := seedMainDetailImage(t, repo, seedMainDetailSnap(t, repo), "x", 1)
if err := repo.AttachStreetDraftImages(ctx, draftID, otherMain, []uint32{d2}); !errors.Is(err, ErrInvalidImageGroup) { if err := repo.AttachStreetSnapImages(ctx, snapID, otherMain, []uint32{d2}); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("跨草稿主图应被拦截,实际 %v", err) t.Fatalf("跨记录主图应被拦截,实际 %v", err)
} }
} }
@ -123,18 +139,18 @@ func TestStreetMainDetailReparentGrandchildren(t *testing.T) {
repo := NewReviewRepository(testDB(t)) repo := NewReviewRepository(testDB(t))
ctx := context.Background() ctx := context.Background()
draftID := seedStreetDraft(t, repo) snapID := seedMainDetailSnap(t, repo)
m1 := seedStreetDraftImage(t, repo, draftID, "m1", 1) m1 := seedMainDetailImage(t, repo, snapID, "m1", 1)
m2 := seedStreetDraftImage(t, repo, draftID, "m2", 2) m2 := seedMainDetailImage(t, repo, snapID, "m2", 2)
child := seedStreetDraftImage(t, repo, draftID, "child", 3) child := seedMainDetailImage(t, repo, snapID, "child", 3)
if err := repo.AttachStreetDraftImages(ctx, draftID, m2, []uint32{child}); err != nil { if err := repo.AttachStreetSnapImages(ctx, snapID, m2, []uint32{child}); err != nil {
t.Fatalf("预置副图失败: %v", err) t.Fatalf("预置副图失败: %v", err)
} }
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{m2}); err != nil { if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{m2}); err != nil {
t.Fatalf("并入整组失败: %v", err) t.Fatalf("并入整组失败: %v", err)
} }
for _, im := range reloadStreetImages(t, repo, draftID) { for _, im := range reloadSnapImages(t, repo, snapID) {
if im.ID == m2 && (im.IsDetail != 1 || im.ParentImageID != m1) { if im.ID == m2 && (im.IsDetail != 1 || im.ParentImageID != m1) {
t.Fatalf("m2 应成为 m1 的副图,实际 %d/%d", im.IsDetail, im.ParentImageID) t.Fatalf("m2 应成为 m1 的副图,实际 %d/%d", im.IsDetail, im.ParentImageID)
} }
@ -143,50 +159,3 @@ func TestStreetMainDetailReparentGrandchildren(t *testing.T) {
} }
} }
} }
// TestStreetPromoteRebuildsParentRef 晋升时必须把副图的父引用从「草稿行 id」改写为「新正式行 id」。
func TestStreetPromoteRebuildsParentRef(t *testing.T) {
repo := NewReviewRepository(testDB(t))
ctx := context.Background()
draftID := seedStreetDraft(t, repo)
m1 := seedStreetDraftImage(t, repo, draftID, "main", 1)
d2 := seedStreetDraftImage(t, repo, draftID, "detail-a", 2)
d3 := seedStreetDraftImage(t, repo, draftID, "detail-b", 3)
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil {
t.Fatalf("预置主副图失败: %v", err)
}
snapID, err := repo.SaveStreetSnapFromDraft(ctx, draftID)
if err != nil {
t.Fatalf("晋升失败: %v", err)
}
t.Cleanup(func() {
db := repo.(*reviewRepository).db
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snapID)
db.Exec("DELETE FROM street_snaps WHERE id = ?", snapID)
})
db := repo.(*reviewRepository).db
var rows []model.StreetSnapImage
if err := db.WithContext(ctx).Where("snap_id = ? AND is_deleted = 0", snapID).
Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
t.Fatalf("读正式图失败: %v", err)
}
if len(rows) != 3 {
t.Fatalf("期望 3 行正式图,实际 %d", len(rows))
}
mainRow := rows[0]
if mainRow.IsDetail != 0 || mainRow.ParentImageID != 0 {
t.Fatalf("首行应是主图,实际 is_detail=%d parent=%d", mainRow.IsDetail, mainRow.ParentImageID)
}
for _, row := range rows[1:] {
if row.IsDetail != 1 {
t.Fatalf("第 %d 行应是副图", row.ID)
}
if row.ParentImageID != mainRow.ID {
t.Fatalf("副图 parent 应指向新主图行 id %d,实际 %d", mainRow.ID, row.ParentImageID)
}
}
}

View File

@ -80,8 +80,8 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
// 审核页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。 // 审核页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
// 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。 // 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。
admin.POST("/api/reviews/batch", opt.Backstage.ReviewBatch) admin.POST("/api/reviews/batch", opt.Backstage.ReviewBatch)
admin.POST("/api/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImages) admin.POST("/api/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImagesAPI)
admin.POST("/api/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImage) admin.POST("/api/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImageAPI)
admin.GET("/", opt.Backstage.Dashboard) admin.GET("/", opt.Backstage.Dashboard)
admin.GET("/brands", opt.Backstage.Brands) admin.GET("/brands", opt.Backstage.Brands)
@ -103,17 +103,17 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
admin.POST("/street-snaps/:id/images/:img/delete", opt.Backstage.StreetSnapImageDelete) 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/cover", opt.Backstage.StreetSnapSetCover)
admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted) admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted)
// 审核草稿:爬虫入库待人工审核 → 通过晋升正式表 / 驳回。 // 审核记录:爬虫入库待人工审核 → 通过置为已发布 / 驳回。
// 路由带 :kind 段(runway / street / 缺省=全部),详情/通过/驳回均按 kind 分流。 // 路由带 :kind 段(runway / street / 缺省=全部),详情/通过/驳回均按 kind 分流。
admin.GET("/reviews", opt.Backstage.ReviewList) admin.GET("/reviews", opt.Backstage.ReviewList)
admin.GET("/reviews/:kind", opt.Backstage.ReviewList) admin.GET("/reviews/:kind", opt.Backstage.ReviewList)
admin.GET("/reviews/:kind/:id", opt.Backstage.ReviewDetail) admin.GET("/reviews/:kind/:id", opt.Backstage.ReviewDetail)
admin.POST("/reviews/:kind/:id/approve", opt.Backstage.ReviewApprove) admin.POST("/reviews/:kind/:id/approve", opt.Backstage.ReviewApprove)
admin.POST("/reviews/:kind/:id/reject", opt.Backstage.ReviewReject) admin.POST("/reviews/:kind/:id/reject", opt.Backstage.ReviewReject)
admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewDraftImageDelete) admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewImageDelete)
// 街拍主副图分组操作(零 JS:同步表单 POST,成功 302 回详情页)。 // 街拍主副图分组操作(零 JS:同步表单 POST,成功 302 回详情页)。
admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachDraftImages) admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImages)
admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachDraftImage) admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImage)
// 爬虫入库任务监控:列出处理进度 + 失败重试 // 爬虫入库任务监控:列出处理进度 + 失败重试
admin.GET("/ingest-jobs", opt.Backstage.IngestJobs) admin.GET("/ingest-jobs", opt.Backstage.IngestJobs)
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry) admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)

View File

@ -333,12 +333,12 @@ type fakeReview struct {
approveErr error approveErr error
rejectErr error rejectErr error
last struct { last struct {
kind string kind string
draftID uint32 recordID uint32
mainID uint32 mainID uint32
imgIDs []uint32 imgIDs []uint32
imgID uint32 imgID uint32
op string op string
} }
// calls 记录批量审核的逐条调用,供断言。 // calls 记录批量审核的逐条调用,供断言。
calls []struct { calls []struct {
@ -347,27 +347,32 @@ type fakeReview struct {
ID uint32 ID uint32
Reason string Reason string
} }
view *service.DraftDetailView view *service.RecordDetailView
} }
var _ service.ReviewService = (*fakeReview)(nil) var _ service.ReviewService = (*fakeReview)(nil)
func (f *fakeReview) DraftDetail(_ context.Context, _ string, _ uint32) (*service.DraftDetailView, error) { func (f *fakeReview) RecordDetail(_ context.Context, _ string, _ uint32) (*service.RecordDetailView, error) {
if f.view != nil { if f.view != nil {
return f.view, nil return f.view, nil
} }
return &service.DraftDetailView{Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 2, Status: "pending"}, nil return &service.RecordDetailView{Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 2, Status: "pending"}, nil
} }
func (f *fakeReview) AttachDraftImages(_ context.Context, kind string, draftID, mainID uint32, imgIDs []uint32) error { // UpdateRecord 表单式通过路径会先应用字段微调;这里只做无操作桩(真正的白名单在仓储层)。
f.last.kind, f.last.draftID, f.last.mainID, f.last.imgIDs, f.last.op = kind, draftID, mainID, imgIDs, "attach" func (f *fakeReview) UpdateRecord(_ context.Context, _ string, _ uint32, _ map[string]any) error {
return nil
}
func (f *fakeReview) AttachImages(_ context.Context, kind string, recordID, mainID uint32, imgIDs []uint32) error {
f.last.kind, f.last.recordID, f.last.mainID, f.last.imgIDs, f.last.op = kind, recordID, mainID, imgIDs, "attach"
if !f.attachOK { if !f.attachOK {
return errors.New("主图无效") return errors.New("主图无效")
} }
return nil return nil
} }
func (f *fakeReview) DetachDraftImage(_ context.Context, kind string, draftID, imgID uint32) error { func (f *fakeReview) DetachImage(_ context.Context, kind string, recordID, imgID uint32) error {
f.last.kind, f.last.draftID, f.last.imgID, f.last.op = kind, draftID, imgID, "detach" f.last.kind, f.last.recordID, f.last.imgID, f.last.op = kind, recordID, imgID, "detach"
if !f.attachOK { if !f.attachOK {
return errors.New("主图无效") return errors.New("主图无效")
} }
@ -391,6 +396,8 @@ func (f *fakeReview) Approve(_ context.Context, kind string, id uint32, reviewer
ID uint32 ID uint32
Reason string Reason string
}{"approve", kind, id, ""}) }{"approve", kind, id, ""})
// last 供「表单式通过」用例断言(批量接口用 calls)。
f.last.kind, f.last.recordID, f.last.op = kind, id, "approve"
if f.approveErr != nil { if f.approveErr != nil {
return 0, f.approveErr return 0, f.approveErr
} }
@ -494,16 +501,16 @@ func TestBackstageStreetDetach(t *testing.T) {
func TestBackstageStreetRenderGroups(t *testing.T) { func TestBackstageStreetRenderGroups(t *testing.T) {
r, rev := newReviewRouter(t) r, rev := newReviewRouter(t)
tok := reviewToken(t, r) tok := reviewToken(t, r)
rev.view = &service.DraftDetailView{ rev.view = &service.RecordDetailView{
Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 3, Status: "pending", Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 3, Status: "pending",
Images: []service.DraftImageRef{ Images: []service.RecordImageRef{
{ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0, DetailCount: 1}, {ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0, DetailCount: 1},
{ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1}, {ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0}, {ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0},
}, },
Groups: []service.DraftImageGroup{ Groups: []service.ImageGroup{
{Image: service.DraftImageRef{ID: 1, Image: "main.jpg", Name: "main"}, Details: []service.DraftImageRef{{ID: 2, Image: "det.jpg", Name: "detail-a"}}}, {Image: service.RecordImageRef{ID: 1, Image: "main.jpg", Name: "main"}, Details: []service.RecordImageRef{{ID: 2, Image: "det.jpg", Name: "detail-a"}}},
{Image: service.DraftImageRef{ID: 3, Image: "solo.jpg", Name: "solo"}}, {Image: service.RecordImageRef{ID: 3, Image: "solo.jpg", Name: "solo"}},
}, },
} }
@ -538,6 +545,26 @@ func TestBackstageStreetRenderGroups(t *testing.T) {
} }
} }
// TestBackstageApprovePublishes 通过 = 只改状态,不再晋升重建。
// 断言表单式通过路径确实走到了 service.Approve(而不是别的方法)。
func TestBackstageApprovePublishes(t *testing.T) {
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/approve", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("通过应 302,实际 %d %s", w.Code, w.Body.String())
}
if rev.last.op != "approve" {
t.Fatalf("应调用 Approve,实际 op=%s", rev.last.op)
}
}
// ── 新增:静态资源、导航统计 & 批量审核 AJAX 接口 ─────────────────────────── // ── 新增:静态资源、导航统计 & 批量审核 AJAX 接口 ───────────────────────────
// TestBackstageAssets 后台的样式与脚本由二进制内嵌提供:零外部依赖、零构建产物。 // TestBackstageAssets 后台的样式与脚本由二进制内嵌提供:零外部依赖、零构建产物。

View File

@ -89,7 +89,7 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu
return list, total, nil return list, total, nil
} }
// AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 / // AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 / 状态
// 管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。 // 管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。
func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) { func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) {
q.Normalize() q.Normalize()
@ -109,6 +109,7 @@ func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]d
CollectionType: r.CollectionType, CollectionType: r.CollectionType,
SeasonCode: r.SeasonCode, SeasonCode: r.SeasonCode,
ImageCount: r.ImageCount, ImageCount: r.ImageCount,
Status: r.Status,
}) })
} }
return list, total, nil return list, total, nil

View File

@ -13,8 +13,8 @@ import (
"fashionapi/internal/repository" "fashionapi/internal/repository"
) )
// DraftCard 审核列表统一卡片(所有模块共用),供后台列表页渲染。 // RecordCard 审核列表统一卡片(所有模块共用),供后台列表页渲染。
type DraftCard struct { type RecordCard struct {
Kind string `json:"kind"` // runway | street | 未来其它模块 Kind string `json:"kind"` // runway | street | 未来其它模块
ID uint32 `json:"id"` ID uint32 `json:"id"`
Title string `json:"title"` // 展示标题 Title string `json:"title"` // 展示标题
@ -26,11 +26,11 @@ type DraftCard struct {
UpdatedAt uint32 `json:"updated_at"` UpdatedAt uint32 `json:"updated_at"`
} }
// DraftImageRef 草稿图片引用(统一结构)。 // RecordImageRef 记录图片引用(统一结构)。
// //
// IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用); // IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用);
// DetailCount 让模板不用自己数副图。 // DetailCount 让模板不用自己数副图。
type DraftImageRef struct { type RecordImageRef struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
Image string `json:"image"` Image string `json:"image"`
Name string `json:"name"` Name string `json:"name"`
@ -49,39 +49,39 @@ type EditField struct {
Type string `json:"type"` // text | textarea | number Type string `json:"type"` // text | textarea | number
} }
// DraftDetailView 审核详情统一视图(所有模块共用)。 // RecordDetailView 审核详情统一视图(所有模块共用)。
type DraftDetailView struct { type RecordDetailView struct {
Kind string `json:"kind"` Kind string `json:"kind"`
ID uint32 `json:"id"` ID uint32 `json:"id"`
BrandID uint32 `json:"brand_id"` // 仅 runway 等有品牌的模块有,其余为 0 BrandID uint32 `json:"brand_id"` // 仅 runway 等有品牌的模块有,其余为 0
Title string `json:"title"` // 展示标题(runway=title_en,street=title) Title string `json:"title"` // 展示标题(runway=title_en,street=title)
TitleEn string `json:"title_en"` TitleEn string `json:"title_en"`
TitleCn string `json:"title_cn"` TitleCn string `json:"title_cn"`
DescriptionEn string `json:"description_en"` DescriptionEn string `json:"description_en"`
DescriptionCn string `json:"description_cn"` DescriptionCn string `json:"description_cn"`
Year uint16 `json:"year"` Year uint16 `json:"year"`
Season string `json:"season"` Season string `json:"season"`
CollectionType string `json:"collection_type"` CollectionType string `json:"collection_type"`
SeasonCode string `json:"season_code"` SeasonCode string `json:"season_code"`
City string `json:"city"` City string `json:"city"`
Cover string `json:"cover"` Cover string `json:"cover"`
ImageCount uint16 `json:"image_count"` ImageCount uint16 `json:"image_count"`
Status string `json:"status"` Status string `json:"status"`
Images []DraftImageRef `json:"images"` Images []RecordImageRef `json:"images"`
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。 // Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
Fields []EditField `json:"fields"` Fields []EditField `json:"fields"`
// Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。 // Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。
// 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。 // 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。
Groups []DraftImageGroup `json:"groups,omitempty"` Groups []ImageGroup `json:"groups,omitempty"`
} }
// DraftImageGroup 一张主图及其副图,供审核页按组折叠渲染。 // ImageGroup 一张主图及其副图,供审核页按组折叠渲染。
type DraftImageGroup struct { type ImageGroup struct {
Image DraftImageRef `json:"image"` // 主图 Image RecordImageRef `json:"image"` // 主图
Details []DraftImageRef `json:"details"` // 该主图名下的副图(可为空) Details []RecordImageRef `json:"details"` // 该主图名下的副图(可为空)
} }
// ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于本草稿 / 自身不是主图 / 空集合。 // ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于该记录 / 自身不是主图 / 空集合。
// 这里是给用户看的文案,handler 直接渲染 err.Error()。 // 这里是给用户看的文案,handler 直接渲染 err.Error()。
var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本草稿、未被删除,且自身是主图") var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本草稿、未被删除,且自身是主图")
@ -92,40 +92,41 @@ type KindTab struct {
Pending int64 `json:"pending"` Pending int64 `json:"pending"`
} }
// ReviewModule 一种待审草稿模块(runway / street / 未来任意爬虫数据类型)的统一契约。 // ReviewModule 一种待审记录模块(runway / street / 未来任意爬虫数据类型)的统一契约。
// //
// 新增一种爬虫数据类型需要审核时,只需: // 新增一种爬虫数据类型需要审核时,只需:
// 1. 建草稿表 + 迁移; // 1. 建正式表 + 迁移(记录自带 status 列);
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/SaveFromDraft/CountPending; // 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/CountByStatus;
// 3. 在此文件实现本接口并注册到 NewReviewService。 // 3. 在此文件实现本接口并注册到 NewReviewService。
// //
// 列表、详情、通过、驳回、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。 // 列表、详情、通过、驳回、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
type ReviewModule interface { type ReviewModule interface {
Kind() string Kind() string
Label() string Label() string
ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error)
DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error)
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
// Approve 晋升到正式表并把草稿置 approved;返回正式表主键。 // Approve 把正式记录置为 published;返回记录主键。
// 单表发布模型:通过只是改状态,不再晋升重建(图片在入库时已落到正式表)。
Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error)
Reject(ctx context.Context, id uint32, reviewer, reason string) error Reject(ctx context.Context, id uint32, reviewer, reason string) error
CountPending(ctx context.Context) (int64, error) CountPending(ctx context.Context) (int64, error)
} }
// ReviewService 审核草稿的业务逻辑:列出 / 查看 / 微调 / 通过(晋升)/ 驳回。 // ReviewService 审核的业务逻辑:列出 / 查看 / 微调 / 通过(置 published)/ 驳回。
// 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。 // 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。
type ReviewService interface { type ReviewService interface {
ListDrafts(ctx context.Context, kind, status string, page, size int) ([]DraftCard, int64, error) ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error)
DraftDetail(ctx context.Context, kind string, id uint32) (*DraftDetailView, error) RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error)
UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error
Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, 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 Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删S4文件)。 // DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error
// AttachDraftImages 把若干张草稿图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。 // AttachImages 把若干张图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
AttachDraftImages(ctx context.Context, kind string, draftID, mainImgID uint32, imgIDs []uint32) error AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error
// DetachDraftImage 把副图拆出恢复为主图(街拍专用)。 // DetachImage 把副图拆出恢复为主图(街拍专用)。
DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error DetachImage(ctx context.Context, kind string, recordID, imgID uint32) error
// KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。 // KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。
KindTabs(ctx context.Context) []KindTab KindTabs(ctx context.Context) []KindTab
} }
@ -165,22 +166,22 @@ func (s *reviewService) module(kind string) (ReviewModule, bool) {
return m, ok return m, ok
} }
func (s *reviewService) ListDrafts(ctx context.Context, kind, status string, page, size int) ([]DraftCard, int64, error) { func (s *reviewService) ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error) {
if kind == "all" { if kind == "all" {
kind = "" kind = ""
} }
if kind != "" { if kind != "" {
m, ok := s.module(kind) m, ok := s.module(kind)
if !ok { if !ok {
return []DraftCard{}, 0, nil return []RecordCard{}, 0, nil
} }
return m.ListDrafts(ctx, status, page, size) return m.ListRecords(ctx, status, page, size)
} }
// 合并所有模块:各取较多行后在内存按创建时间倒序,再分页(后台量小,足够准确)。 // 合并所有模块:各取较多行后在内存按创建时间倒序,再分页(后台量小,足够准确)。
var all []DraftCard var all []RecordCard
var total int64 var total int64
for _, k := range s.order { for _, k := range s.order {
cards, t, err := s.modules[k].ListDrafts(ctx, status, 1, 1000) cards, t, err := s.modules[k].ListRecords(ctx, status, 1, 1000)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -208,20 +209,20 @@ func (s *reviewService) ListDrafts(ctx context.Context, kind, status string, pag
return all[start:end], total, nil return all[start:end], total, nil
} }
func (s *reviewService) DraftDetail(ctx context.Context, kind string, id uint32) (*DraftDetailView, error) { func (s *reviewService) RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error) {
m, ok := s.module(kind) m, ok := s.module(kind)
if !ok { if !ok {
return nil, repository.ErrNotFound return nil, repository.ErrNotFound
} }
return m.DraftDetail(ctx, id) return m.RecordDetail(ctx, id)
} }
func (s *reviewService) UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error { func (s *reviewService) UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error {
m, ok := s.module(kind) m, ok := s.module(kind)
if !ok { if !ok {
return repository.ErrNotFound return repository.ErrNotFound
} }
return m.UpdateDraft(ctx, id, fields) return m.UpdateRecord(ctx, id, fields)
} }
func (s *reviewService) Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error) { func (s *reviewService) Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error) {
@ -256,22 +257,22 @@ func (s *reviewService) KindTabs(ctx context.Context) []KindTab {
return tabs return tabs
} }
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图)。 // DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图)。
// 按 kind 委派到对应草稿图片表的软删,并同步草稿 image_count; // 按 kind 委派到对应图片表的软删,并同步记录 image_count;
// 若该 key 在全库已无其他引用(引用计数归零),则一并清理S4孤儿文件。 // 若该 key 在全库已无其他引用(引用计数归零),则一并清理S4孤儿文件。
func (s *reviewService) DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error { func (s *reviewService) DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error {
if _, ok := s.module(kind); !ok { if _, ok := s.module(kind); !ok {
return repository.ErrNotFound return repository.ErrNotFound
} }
key, err := s.repo.GetDraftImageKey(ctx, kind, draftID, imgID) key, err := s.repo.GetImageKey(ctx, kind, recordID, imgID)
if err != nil { if err != nil {
return err return err
} }
switch kind { switch kind {
case dto.IngestKindRunway: case dto.IngestKindRunway:
err = s.repo.SoftDeleteRunwayDraftImage(ctx, draftID, imgID) err = s.repo.SoftDeleteRunwayImage(ctx, recordID, imgID)
case dto.IngestKindStreet: case dto.IngestKindStreet:
err = s.repo.SoftDeleteStreetDraftImage(ctx, draftID, imgID) err = s.repo.SoftDeleteStreetImage(ctx, recordID, imgID)
default: default:
return repository.ErrNotFound return repository.ErrNotFound
} }
@ -284,21 +285,21 @@ func (s *reviewService) DeleteDraftImage(ctx context.Context, kind string, draft
return nil return nil
} }
func (s *reviewService) AttachDraftImages(ctx context.Context, kind string, draftID, mainImgID uint32, imgIDs []uint32) error { func (s *reviewService) AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error {
if kind != dto.IngestKindStreet { if kind != dto.IngestKindStreet {
return ErrInvalidImageGroup return ErrInvalidImageGroup
} }
if len(imgIDs) == 0 { if len(imgIDs) == 0 {
return ErrInvalidImageGroup return ErrInvalidImageGroup
} }
return translateGroupErr(s.repo.AttachStreetDraftImages(ctx, draftID, mainImgID, imgIDs)) return translateGroupErr(s.repo.AttachStreetSnapImages(ctx, recordID, mainImgID, imgIDs))
} }
func (s *reviewService) DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error { func (s *reviewService) DetachImage(ctx context.Context, kind string, recordID, imgID uint32) error {
if kind != dto.IngestKindStreet { if kind != dto.IngestKindStreet {
return ErrInvalidImageGroup return ErrInvalidImageGroup
} }
return translateGroupErr(s.repo.DetachStreetDraftImage(ctx, draftID, imgID)) return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID))
} }
// translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。 // translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。
@ -322,24 +323,24 @@ type runwayModule struct {
func (m *runwayModule) Kind() string { return dto.IngestKindRunway } func (m *runwayModule) Kind() string { return dto.IngestKindRunway }
func (m *runwayModule) Label() string { return "走秀" } func (m *runwayModule) Label() string { return "走秀" }
func (m *runwayModule) ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) { func (m *runwayModule) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) {
rows, total, err := m.repo.ListDrafts(ctx, status, page, size) rows, total, err := m.repo.ListRecords(ctx, status, page, size)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
return m.toRunwayCards(rows), total, nil return m.toRunwayCards(rows), total, nil
} }
func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) { func (m *runwayModule) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) {
d, err := m.repo.DraftDetail(ctx, id) d, err := m.repo.RecordDetail(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
imgs, err := m.repo.DraftImages(ctx, id) imgs, err := m.repo.RecordImages(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
v := &DraftDetailView{ v := &RecordDetailView{
Kind: dto.IngestKindRunway, Kind: dto.IngestKindRunway,
ID: d.ID, ID: d.ID,
BrandID: d.BrandID, BrandID: d.BrandID,
@ -367,32 +368,28 @@ func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
}, },
} }
for _, im := range imgs { for _, im := range imgs {
v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder}) v.Images = append(v.Images, RecordImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder})
} }
return v, nil return v, nil
} }
func (m *runwayModule) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error { func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
return m.repo.UpdateDraft(ctx, id, fields) return m.repo.UpdateRecord(ctx, id, fields)
} }
func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) { func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
officialID, err := m.repo.SaveRunwayFromDraft(ctx, id) if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil {
if err != nil {
return 0, err return 0, err
} }
if err := m.repo.SetDraftStatus(ctx, id, model.DraftStatusApproved, reviewer, ""); err != nil { return id, nil
return 0, err
}
return officialID, nil
} }
func (m *runwayModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error { func (m *runwayModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
return m.repo.SetDraftStatus(ctx, id, model.DraftStatusRejected, reviewer, reason) return m.repo.SetRecordStatus(ctx, id, model.StatusRejected, reviewer, reason)
} }
func (m *runwayModule) CountPending(ctx context.Context) (int64, error) { func (m *runwayModule) CountPending(ctx context.Context) (int64, error) {
return m.repo.CountDraftPending(ctx, model.DraftStatusPending, &model.BrandRunwayDraft{}) return m.repo.CountByStatus(ctx, model.StatusPending, &model.BrandRunway{})
} }
// ── street 模块 ──────────────────────────────────────────────────────────────── // ── street 模块 ────────────────────────────────────────────────────────────────
@ -405,24 +402,24 @@ type streetModule struct {
func (m *streetModule) Kind() string { return dto.IngestKindStreet } func (m *streetModule) Kind() string { return dto.IngestKindStreet }
func (m *streetModule) Label() string { return "街拍" } func (m *streetModule) Label() string { return "街拍" }
func (m *streetModule) ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) { func (m *streetModule) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) {
rows, total, err := m.repo.ListStreetDrafts(ctx, status, page, size) rows, total, err := m.repo.ListStreetRecords(ctx, status, page, size)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
return m.toStreetCards(rows), total, nil return m.toStreetCards(rows), total, nil
} }
func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) { func (m *streetModule) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) {
d, err := m.repo.StreetDraftDetail(ctx, id) d, err := m.repo.StreetRecordDetail(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
imgs, err := m.repo.StreetDraftImages(ctx, id) imgs, err := m.repo.StreetRecordImages(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
v := &DraftDetailView{ v := &RecordDetailView{
Kind: dto.IngestKindStreet, Kind: dto.IngestKindStreet,
ID: d.ID, ID: d.ID,
Title: d.Title, Title: d.Title,
@ -440,7 +437,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
// 按 sort_order 顺序成组:主图各成一组;副图归入其(存活)主图组,父引用失效则自成一主图组。 // 按 sort_order 顺序成组:主图各成一组;副图归入其(存活)主图组,父引用失效则自成一主图组。
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图(保证与公开 API 同源同序)。 // 父引用指向「副图」时沿链向上归并到该副图所在组的主图(保证与公开 API 同源同序)。
byID := make(map[uint32]model.StreetSnapDraftImage, len(imgs)) byID := make(map[uint32]model.StreetSnapImage, len(imgs))
for _, im := range imgs { for _, im := range imgs {
byID[im.ID] = im byID[im.ID] = im
} }
@ -472,7 +469,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
} }
} }
groups := make([]DraftImageGroup, 0, len(imgs)) groups := make([]ImageGroup, 0, len(imgs))
groupIndex := make(map[uint32]int, len(imgs)) groupIndex := make(map[uint32]int, len(imgs))
for _, im := range imgs { for _, im := range imgs {
root := resolveRoot(im.ID) root := resolveRoot(im.ID)
@ -483,7 +480,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
isDetailVal = 1 isDetailVal = 1
parent = root parent = root
} }
ref := DraftImageRef{ ref := RecordImageRef{
ID: im.ID, ID: im.ID,
Image: m.img.Compose(im.Image), Image: m.img.Compose(im.Image),
Name: im.Name, Name: im.Name,
@ -494,14 +491,14 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
} }
v.Images = append(v.Images, ref) v.Images = append(v.Images, ref)
if !isDetail { if !isDetail {
groups = append(groups, DraftImageGroup{Image: ref}) groups = append(groups, ImageGroup{Image: ref})
groupIndex[im.ID] = len(groups) - 1 groupIndex[im.ID] = len(groups) - 1
continue continue
} }
if gi, ok := groupIndex[root]; ok { if gi, ok := groupIndex[root]; ok {
groups[gi].Details = append(groups[gi].Details, ref) groups[gi].Details = append(groups[gi].Details, ref)
} else { // 兜底(理论不发生):主图未先出现,自成一组 } else { // 兜底(理论不发生):主图未先出现,自成一组
groups = append(groups, DraftImageGroup{Image: ref}) groups = append(groups, ImageGroup{Image: ref})
groupIndex[im.ID] = len(groups) - 1 groupIndex[im.ID] = len(groups) - 1
} }
} }
@ -509,33 +506,29 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
return v, nil return v, nil
} }
func (m *streetModule) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error { func (m *streetModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
return m.repo.UpdateStreetDraft(ctx, id, fields) return m.repo.UpdateStreetRecord(ctx, id, fields)
} }
func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) { func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
officialID, err := m.repo.SaveStreetSnapFromDraft(ctx, id) if err := m.repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil {
if err != nil {
return 0, err return 0, err
} }
if err := m.repo.SetStreetDraftStatus(ctx, id, model.DraftStatusApproved, reviewer, ""); err != nil { return id, nil
return 0, err
}
return officialID, nil
} }
func (m *streetModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error { func (m *streetModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
return m.repo.SetStreetDraftStatus(ctx, id, model.DraftStatusRejected, reviewer, reason) return m.repo.SetStreetRecordStatus(ctx, id, model.StatusRejected, reviewer, reason)
} }
func (m *streetModule) CountPending(ctx context.Context) (int64, error) { func (m *streetModule) CountPending(ctx context.Context) (int64, error) {
return m.repo.CountDraftPending(ctx, model.DraftStatusPending, &model.StreetSnapDraft{}) return m.repo.CountByStatus(ctx, model.StatusPending, &model.StreetSnap{})
} }
// ── 卡片转换(保留原逻辑)────────────────────────────────────────────────────── // ── 卡片转换(保留原逻辑)──────────────────────────────────────────────────────
func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard { func (m *runwayModule) toRunwayCards(rows []model.BrandRunway) []RecordCard {
cards := make([]DraftCard, 0, len(rows)) cards := make([]RecordCard, 0, len(rows))
for _, d := range rows { for _, d := range rows {
sub := d.SeasonCode sub := d.SeasonCode
if d.CollectionType != "" { if d.CollectionType != "" {
@ -544,7 +537,7 @@ func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard
if sub == "" { if sub == "" {
sub = "—" sub = "—"
} }
cards = append(cards, DraftCard{ cards = append(cards, RecordCard{
Kind: dto.IngestKindRunway, Kind: dto.IngestKindRunway,
ID: d.ID, ID: d.ID,
Title: d.TitleEn, Title: d.TitleEn,
@ -559,14 +552,14 @@ func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard
return cards return cards
} }
func (m *streetModule) toStreetCards(rows []model.StreetSnapDraft) []DraftCard { func (m *streetModule) toStreetCards(rows []model.StreetSnap) []RecordCard {
cards := make([]DraftCard, 0, len(rows)) cards := make([]RecordCard, 0, len(rows))
for _, d := range rows { for _, d := range rows {
sub := d.City sub := d.City
if sub == "" { if sub == "" {
sub = "—" sub = "—"
} }
cards = append(cards, DraftCard{ cards = append(cards, RecordCard{
Kind: dto.IngestKindStreet, Kind: dto.IngestKindStreet,
ID: d.ID, ID: d.ID,
Title: d.Title, Title: d.Title,

View File

@ -14,27 +14,27 @@ import (
// fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。 // fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。
type fakeGroupRepo struct { type fakeGroupRepo struct {
repository.ReviewRepository repository.ReviewRepository
images []model.StreetSnapDraftImage images []model.StreetSnapImage
detail *model.StreetSnapDraft detail *model.StreetSnap
attachFn func(draftID, mainID uint32, imgIDs []uint32) error attachFn func(snapID, mainID uint32, imgIDs []uint32) error
} }
func (f *fakeGroupRepo) StreetDraftDetail(_ context.Context, _ uint32) (*model.StreetSnapDraft, error) { func (f *fakeGroupRepo) StreetRecordDetail(_ context.Context, _ uint32) (*model.StreetSnap, error) {
return f.detail, nil return f.detail, nil
} }
func (f *fakeGroupRepo) StreetDraftImages(_ context.Context, _ uint32) ([]model.StreetSnapDraftImage, error) { func (f *fakeGroupRepo) StreetRecordImages(_ context.Context, _ uint32) ([]model.StreetSnapImage, error) {
return f.images, nil return f.images, nil
} }
func (f *fakeGroupRepo) AttachStreetDraftImages(_ context.Context, draftID, mainID uint32, imgIDs []uint32) error { func (f *fakeGroupRepo) AttachStreetSnapImages(_ context.Context, snapID, mainID uint32, imgIDs []uint32) error {
if f.attachFn != nil { if f.attachFn != nil {
return f.attachFn(draftID, mainID, imgIDs) return f.attachFn(snapID, mainID, imgIDs)
} }
return nil return nil
} }
func (f *fakeGroupRepo) DetachStreetDraftImage(_ context.Context, _ uint32, _ uint32) error { func (f *fakeGroupRepo) DetachStreetSnapImage(_ context.Context, _ uint32, _ uint32) error {
if f.attachFn != nil { if f.attachFn != nil {
return f.attachFn(0, 0, nil) return f.attachFn(0, 0, nil)
} }
@ -50,17 +50,17 @@ func newGroupService(f *fakeGroupRepo) ReviewService {
// TestStreetGroupDetailExposesGrouping 详情必须输出可折叠分组:主图各成一组,副图归入主图。 // TestStreetGroupDetailExposesGrouping 详情必须输出可折叠分组:主图各成一组,副图归入主图。
func TestStreetGroupDetailExposesGrouping(t *testing.T) { func TestStreetGroupDetailExposesGrouping(t *testing.T) {
repo := &fakeGroupRepo{ repo := &fakeGroupRepo{
detail: &model.StreetSnapDraft{ID: 7, Title: "T", Year: 2026, City: "Beijing", Status: model.DraftStatusPending}, detail: &model.StreetSnap{ID: 7, Title: "T", Year: 2026, City: "Beijing", Status: model.StatusPending},
images: []model.StreetSnapDraftImage{ images: []model.StreetSnapImage{
{ID: 1, Image: "main.jpg", Name: "main", SortOrder: 1, IsDetail: 0, ParentImageID: 0}, {ID: 1, Image: "main.jpg", Name: "main", SortOrder: 1, IsDetail: 0, ParentImageID: 0},
{ID: 2, Image: "det.jpg", Name: "det", SortOrder: 2, IsDetail: 1, ParentImageID: 1}, {ID: 2, Image: "det.jpg", Name: "det", SortOrder: 2, IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "solo.jpg", Name: "solo", SortOrder: 3, IsDetail: 0, ParentImageID: 0}, {ID: 3, Image: "solo.jpg", Name: "solo", SortOrder: 3, IsDetail: 0, ParentImageID: 0},
}, },
} }
svc := newGroupService(repo) svc := newGroupService(repo)
v, err := svc.DraftDetail(context.Background(), dto.IngestKindStreet, 7) v, err := svc.RecordDetail(context.Background(), dto.IngestKindStreet, 7)
if err != nil { if err != nil {
t.Fatalf("DraftDetail 失败: %v", err) t.Fatalf("RecordDetail 失败: %v", err)
} }
if len(v.Images) != 3 { if len(v.Images) != 3 {
t.Fatalf("Images 应保留扁平 3 张,实际 %d", len(v.Images)) t.Fatalf("Images 应保留扁平 3 张,实际 %d", len(v.Images))
@ -85,20 +85,20 @@ func TestStreetGroupDetailExposesGrouping(t *testing.T) {
// TestStreetGroupAttachDelegates 并入必须透传参数到仓储。 // TestStreetGroupAttachDelegates 并入必须透传参数到仓储。
func TestStreetGroupAttachDelegates(t *testing.T) { func TestStreetGroupAttachDelegates(t *testing.T) {
var got struct { var got struct {
draftID uint32 snapID uint32
mainID uint32 mainID uint32
imgIDs []uint32 imgIDs []uint32
} }
repo := &fakeGroupRepo{attachFn: func(draftID, mainID uint32, imgIDs []uint32) error { repo := &fakeGroupRepo{attachFn: func(snapID, mainID uint32, imgIDs []uint32) error {
got.draftID, got.mainID, got.imgIDs = draftID, mainID, imgIDs got.snapID, got.mainID, got.imgIDs = snapID, mainID, imgIDs
return nil return nil
}} }}
svc := newGroupService(repo) svc := newGroupService(repo)
if err := svc.AttachDraftImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3}); err != nil { if err := svc.AttachImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3}); err != nil {
t.Fatalf("AttachDraftImages 失败: %v", err) t.Fatalf("AttachImages 失败: %v", err)
} }
if got.draftID != 7 || got.mainID != 1 || len(got.imgIDs) != 2 { if got.snapID != 7 || got.mainID != 1 || len(got.imgIDs) != 2 {
t.Fatalf("参数透传错误: draftID=%d mainID=%d imgIDs=%v", got.draftID, got.mainID, got.imgIDs) t.Fatalf("参数透传错误: snapID=%d mainID=%d imgIDs=%v", got.snapID, got.mainID, got.imgIDs)
} }
} }
@ -106,20 +106,20 @@ func TestStreetGroupAttachDelegates(t *testing.T) {
func TestStreetGroupNonStreetRejected(t *testing.T) { func TestStreetGroupNonStreetRejected(t *testing.T) {
repo := &fakeGroupRepo{} repo := &fakeGroupRepo{}
svc := newGroupService(repo) svc := newGroupService(repo)
if err := svc.AttachDraftImages(context.Background(), dto.IngestKindRunway, 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) { if err := svc.AttachImages(context.Background(), dto.IngestKindRunway, 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("走秀并入应返回 ErrInvalidImageGroup,实际 %v", err) t.Fatalf("走秀并入应返回 ErrInvalidImageGroup,实际 %v", err)
} }
} }
// TestStreetGroupPreventCrossDraft 仓储拦截跨草稿主图后,service 翻成中文用户错误。 // TestStreetGroupPreventCrossRecord 仓储拦截跨记录主图后,service 翻成中文用户错误。
func TestStreetGroupPreventCrossDraft(t *testing.T) { func TestStreetGroupPreventCrossRecord(t *testing.T) {
repo := &fakeGroupRepo{attachFn: func(_, _ uint32, _ []uint32) error { repo := &fakeGroupRepo{attachFn: func(_, _ uint32, _ []uint32) error {
return repository.ErrInvalidImageGroup return repository.ErrInvalidImageGroup
}} }}
svc := newGroupService(repo) svc := newGroupService(repo)
err := svc.AttachDraftImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3}) err := svc.AttachImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3})
if err == nil { if err == nil {
t.Fatal("跨草稿应报错") t.Fatal("跨记录应报错")
} }
if err.Error() != "主图无效:它必须属于本草稿、未被删除,且自身是主图" { if err.Error() != "主图无效:它必须属于本草稿、未被删除,且自身是主图" {
t.Fatalf("错误文案不符: %q", err.Error()) t.Fatalf("错误文案不符: %q", err.Error())

View File

@ -104,6 +104,7 @@ func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap) []dto.PublicS
Title: r.Title, Title: r.Title,
Cover: s.img.ComposeThumb(r.Cover), Cover: s.img.ComposeThumb(r.Cover),
ImageCount: r.ImageCount, ImageCount: r.ImageCount,
Status: r.Status, // 仅后台模板渲染状态列用(json:"-" 不进公开响应)
}) })
} }
return list return list