refactor(publish): 审核侧切到正式表,通过即改状态不再晋升
This commit is contained in:
@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 对外只读街拍详情:与走秀详情同形(主图 + 细节图)。
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}}
|
||||||
|
|||||||
@ -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}}
|
||||||
|
|||||||
@ -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 保证品牌被软删时文章依然可见。
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 后台的样式与脚本由二进制内嵌提供:零外部依赖、零构建产物。
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user