fix(publish): 后台详情与品牌列表改读基表,修复未发布内容在后台不可达
This commit is contained in:
@ -107,7 +107,10 @@ func (h *BackstageHandler) UserSetTier(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/admin/users")
|
c.Redirect(http.StatusFound, "/admin/users")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Brands 品牌管理列表:支持关键词搜索、A-Z 字母索引、分页,复用 BrandService.List。
|
// Brands 品牌管理列表:支持关键词搜索、A-Z 字母索引、分页,复用 BrandService.AdminList。
|
||||||
|
//
|
||||||
|
// 必须用 AdminList:公开 List 的「必须有已发布走秀档案」子查询会把只有待审走秀的品牌挡掉,
|
||||||
|
// 后台需要仍能看到它们(否则待审品牌从后台列表消失)。
|
||||||
func (h *BackstageHandler) Brands(c *gin.Context) {
|
func (h *BackstageHandler) Brands(c *gin.Context) {
|
||||||
q := dto.BrandQuery{
|
q := dto.BrandQuery{
|
||||||
Keyword: c.Query("keyword"),
|
Keyword: c.Query("keyword"),
|
||||||
@ -115,7 +118,7 @@ func (h *BackstageHandler) Brands(c *gin.Context) {
|
|||||||
Page: atoiDefault(c.Query("page"), 1),
|
Page: atoiDefault(c.Query("page"), 1),
|
||||||
Locale: "en",
|
Locale: "en",
|
||||||
}
|
}
|
||||||
items, total, err := h.brand.List(c.Request.Context(), q)
|
items, total, err := h.brand.AdminList(c.Request.Context(), q)
|
||||||
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
|
||||||
@ -225,7 +228,10 @@ func (h *BackstageHandler) Runways(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunwayDetail 走秀详情:展示完整图集,复用 ArticleService.Detail。
|
// RunwayDetail 走秀详情:展示完整图集,复用 ArticleService.DetailAdmin(读基表,含待审)。
|
||||||
|
//
|
||||||
|
// 必须用 DetailAdmin:后台列表刻意展示 pending / rejected 行并链到本页,
|
||||||
|
// 公开的 ArticleService.Detail 读视图会把它们当不存在,导致待审项 404。
|
||||||
// 路由 :id 是 runway 类型化编码(无前缀,类型进密码),按 TypeRunway 解码(与公开接口 article_handler 一致)。
|
// 路由 :id 是 runway 类型化编码(无前缀,类型进密码),按 TypeRunway 解码(与公开接口 article_handler 一致)。
|
||||||
func (h *BackstageHandler) RunwayDetail(c *gin.Context) {
|
func (h *BackstageHandler) RunwayDetail(c *gin.Context) {
|
||||||
raw := c.Param("id")
|
raw := c.Param("id")
|
||||||
@ -234,7 +240,7 @@ func (h *BackstageHandler) RunwayDetail(c *gin.Context) {
|
|||||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw})
|
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
art, err := h.article.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), "en")
|
art, err := h.article.DetailAdmin(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), "en")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw})
|
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw})
|
||||||
return
|
return
|
||||||
@ -278,7 +284,10 @@ func (h *BackstageHandler) StreetSnaps(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreetSnapDetail 街拍详情:展示完整图集,复用 StreetSnapService.Detail。
|
// StreetSnapDetail 街拍详情:展示完整图集,复用 StreetSnapService.DetailAdmin(读基表,含待审)。
|
||||||
|
//
|
||||||
|
// 必须用 DetailAdmin:后台列表刻意展示 pending / rejected 行并链到本页,
|
||||||
|
// 公开的 StreetSnapService.Detail 读视图会把它们当不存在,导致待审项 404。
|
||||||
// 路由 :id 是 snap 类型化编码(无前缀,类型进密码),按 TypeSnap 解码(与公开接口 street_snap_handler 一致)。
|
// 路由 :id 是 snap 类型化编码(无前缀,类型进密码),按 TypeSnap 解码(与公开接口 street_snap_handler 一致)。
|
||||||
func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
|
func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
|
||||||
raw := c.Param("id")
|
raw := c.Param("id")
|
||||||
@ -287,7 +296,7 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
|
|||||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
|
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
snap, err := h.street.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
|
snap, err := h.street.DetailAdmin(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
|
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
|
||||||
return
|
return
|
||||||
|
|||||||
@ -18,10 +18,15 @@ type ArticleRepository interface {
|
|||||||
List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
||||||
// ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code 等管理字段。
|
// ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code 等管理字段。
|
||||||
ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
||||||
// FindByID 查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
|
// FindByID 公开读:查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
|
||||||
FindByID(ctx context.Context, id string) (*model.RunwayRow, error)
|
FindByID(ctx context.Context, id string) (*model.RunwayRow, error)
|
||||||
// ListImages 查询某篇文章的全部图片,按排序值升序。
|
// FindByIDBase 后台读基表:与 FindByID 同投影/同 JOIN,但能看到 pending / rejected。
|
||||||
|
// 后台列表故意展示待审行并链到详情页,详情必须走基表,否则待审项点进去 404。
|
||||||
|
FindByIDBase(ctx context.Context, id string) (*model.RunwayRow, error)
|
||||||
|
// ListImages 公开读:查询某篇文章的全部图片,按排序值升序(未发布图片不可见)。
|
||||||
ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
|
ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
|
||||||
|
// ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。
|
||||||
|
ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
|
||||||
// ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。
|
// ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。
|
||||||
ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error)
|
ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error)
|
||||||
// GetForEdit 取原始走秀记录(含全部 en/cn 字段)与图片,供后台编辑表单预填。
|
// GetForEdit 取原始走秀记录(含全部 en/cn 字段)与图片,供后台编辑表单预填。
|
||||||
@ -196,11 +201,20 @@ func (r *articleRepository) ListAdmin(ctx context.Context, q dto.ArticleQuery) (
|
|||||||
return rows, total, nil
|
return rows, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindByID 公开读:读公开视图,pending / rejected 视同不存在(返回 ErrNotFound)。
|
||||||
func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) {
|
func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) {
|
||||||
|
return r.findByID(r.db.WithContext(ctx).Table(publicRunways), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByIDBase 后台读基表:同投影同 JOIN,但不过滤 status(后台详情需渲染待审内容)。
|
||||||
|
func (r *articleRepository) FindByIDBase(ctx context.Context, id string) (*model.RunwayRow, error) {
|
||||||
|
return r.findByID(r.db.WithContext(ctx).Model(&model.BrandRunway{}), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findByID 用给定查询(视图或基表)取单行;投影与 JOIN 两处保持一致,避免公开/后台口径漂移。
|
||||||
|
func (r *articleRepository) findByID(q *gorm.DB, id string) (*model.RunwayRow, error) {
|
||||||
var row model.RunwayRow
|
var row model.RunwayRow
|
||||||
err := r.db.WithContext(ctx).
|
err := q.Select(articleDetailColumns).
|
||||||
Table(publicRunways).
|
|
||||||
Select(articleDetailColumns).
|
|
||||||
Joins(brandJoin).
|
Joins(brandJoin).
|
||||||
Where("brand_runways.id = ? AND brand_runways.is_deleted = 0", id).
|
Where("brand_runways.id = ? AND brand_runways.is_deleted = 0", id).
|
||||||
First(&row).Error
|
First(&row).Error
|
||||||
@ -224,8 +238,8 @@ func (r *articleRepository) ListImages(ctx context.Context, runwayID string) ([]
|
|||||||
return imgs, err
|
return imgs, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// listImagesBase 读基表(含未发布),仅供后台 GetForEdit 使用 —— 否则编辑待审内容时看不到图。
|
// ListImagesBase 读基表(含未发布),供后台 GetForEdit / 后台详情使用 —— 否则待审内容看不到图。
|
||||||
func (r *articleRepository) listImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
|
func (r *articleRepository) ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
|
||||||
var imgs []model.BrandRunwayImage
|
var imgs []model.BrandRunwayImage
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||||
@ -278,7 +292,7 @@ func (r *articleRepository) GetForEdit(ctx context.Context, id uint32) (*model.B
|
|||||||
}
|
}
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
imgs, err := r.listImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
|
imgs, err := r.ListImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,12 +13,16 @@ import (
|
|||||||
|
|
||||||
// BrandRepository 品牌数据访问接口。
|
// BrandRepository 品牌数据访问接口。
|
||||||
type BrandRepository interface {
|
type BrandRepository interface {
|
||||||
// List 按字母索引 / 关键词分页查询品牌。
|
// List 按字母索引 / 关键词分页查询品牌(公开侧:字母索引只认已发布走秀档案)。
|
||||||
//
|
//
|
||||||
// restrictIDs 用于 featured 模式:非 nil 时结果被限制在这些品牌 id 内;
|
// restrictIDs 用于 featured 模式:非 nil 时结果被限制在这些品牌 id 内;
|
||||||
// 若 q.Featured 为 true 而 restrictIDs 为空,则返回空结果——绝不能退化成"全部品牌"。
|
// 若 q.Featured 为 true 而 restrictIDs 为空,则返回空结果——绝不能退化成"全部品牌"。
|
||||||
List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error)
|
List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error)
|
||||||
|
|
||||||
|
// ListAdmin 后台列表:与 List 同构,但字母索引不要求走秀已发布,
|
||||||
|
// 使只有待审 / 驳回走秀的品牌仍出现在后台(公开侧保护不受影响)。
|
||||||
|
ListAdmin(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error)
|
||||||
|
|
||||||
// FeaturedIDs 返回"代表品牌"的 id 集合:在拥有走秀档案的品牌中,
|
// FeaturedIDs 返回"代表品牌"的 id 集合:在拥有走秀档案的品牌中,
|
||||||
// 按 metric(images = 图片总数 / shows = 发布会数)排名取前 limit 个。
|
// 按 metric(images = 图片总数 / shows = 发布会数)排名取前 limit 个。
|
||||||
FeaturedIDs(ctx context.Context, metric string, limit int) ([]uint32, error)
|
FeaturedIDs(ctx context.Context, metric string, limit int) ([]uint32, error)
|
||||||
@ -71,7 +75,7 @@ func NewBrandRepository(db *gorm.DB) BrandRepository {
|
|||||||
// 排序与索引统一基于 name_en(拉丁规范名);name_en 为空的纯中文品牌归入 OTHER 桶。
|
// 排序与索引统一基于 name_en(拉丁规范名);name_en 为空的纯中文品牌归入 OTHER 桶。
|
||||||
const latinPrefixRegexp = "^[A-Za-z]"
|
const latinPrefixRegexp = "^[A-Za-z]"
|
||||||
|
|
||||||
// hasArticlesSubQuery 子查询:所有「至少有一篇已发布走秀档案」的品牌 id。
|
// hasArticlesSubQuery 子查询(公开侧):所有「至少有一篇已发布走秀档案」的品牌 id。
|
||||||
// 库中存在 2 万+ 无档案品牌,索引必须靠它收敛。
|
// 库中存在 2 万+ 无档案品牌,索引必须靠它收敛。
|
||||||
// 读公开视图而非基表:否则只有待审走秀的品牌会提前出现在前台的 A-Z 索引里。
|
// 读公开视图而非基表:否则只有待审走秀的品牌会提前出现在前台的 A-Z 索引里。
|
||||||
func (r *brandRepository) hasArticlesSubQuery() *gorm.DB {
|
func (r *brandRepository) hasArticlesSubQuery() *gorm.DB {
|
||||||
@ -79,8 +83,20 @@ func (r *brandRepository) hasArticlesSubQuery() *gorm.DB {
|
|||||||
Select("DISTINCT brand_id")
|
Select("DISTINCT brand_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasArticlesSubQueryBase 子查询(后台侧):所有「至少有一篇未删除走秀档案」的品牌 id。
|
||||||
|
// 读基表且不看 status:后台品牌列表必须仍能看到只有待审 / 驳回走秀的品牌,
|
||||||
|
// 否则它们会从后台「消失」,审核员无处下手(与公开侧的保护并不冲突:公开走视图)。
|
||||||
|
func (r *brandRepository) hasArticlesSubQueryBase() *gorm.DB {
|
||||||
|
return r.db.Model(&model.BrandRunway{}).
|
||||||
|
Select("DISTINCT brand_id").
|
||||||
|
Where("is_deleted = 0")
|
||||||
|
}
|
||||||
|
|
||||||
// brandFilterScope 把品牌查询条件编译为 GORM Scope。
|
// brandFilterScope 把品牌查询条件编译为 GORM Scope。
|
||||||
func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint32) func(*gorm.DB) *gorm.DB {
|
//
|
||||||
|
// admin 为 true 时(后台列表)「必须有走秀档案」的子查询读基表且不过滤 status,
|
||||||
|
// 使只有待审 / 驳回走秀的品牌仍出现在后台;公开列表保持读视图(保护未发布内容)。
|
||||||
|
func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint32, admin bool) func(*gorm.DB) *gorm.DB {
|
||||||
return func(db *gorm.DB) *gorm.DB {
|
return func(db *gorm.DB) *gorm.DB {
|
||||||
db = db.Where("is_deleted = 0")
|
db = db.Where("is_deleted = 0")
|
||||||
|
|
||||||
@ -96,13 +112,20 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 字母索引始终只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌,
|
// 字母索引始终只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌,
|
||||||
// 不过滤会污染 A-Z 索引)。原 only_with_articles 参数已删除,此过滤无条件生效。
|
// 不过滤会污染 A-Z 索引)。原 only_with_articles 参数已删除,此过滤无条件生效;
|
||||||
|
// 唯一区别是"档案"的口径:公开=已发布(视图),后台=未删除(基表,含待审)。
|
||||||
// 例外:关键词搜索时不限制"必须有走秀档案"(用户按名字找品牌,
|
// 例外:关键词搜索时不限制"必须有走秀档案"(用户按名字找品牌,
|
||||||
// 即使该品牌暂无走秀档案也应被命中,如 A Bathing Ape / 猿人头);
|
// 即使该品牌暂无走秀档案也应被命中,如 A Bathing Ape / 猿人头);
|
||||||
// 中文分桶(OTHER)同样列出全部中文名品牌便于检索。
|
// 中文分桶(OTHER)同样列出全部中文名品牌便于检索。
|
||||||
if q.Keyword == "" && q.Letter != "OTHER" {
|
if q.Keyword == "" && q.Letter != "OTHER" {
|
||||||
|
if admin {
|
||||||
|
// 后台:只要求「存在未删除走秀档案」,待审 / 驳回的品牌也带出来。
|
||||||
|
db = db.Where("id IN (?)", r.hasArticlesSubQueryBase())
|
||||||
|
} else {
|
||||||
|
// 公开:只认已发布走秀(视图),未发布内容不得出现在前台 A-Z 索引。
|
||||||
db = db.Where("id IN (?)", r.hasArticlesSubQuery())
|
db = db.Where("id IN (?)", r.hasArticlesSubQuery())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if q.Keyword != "" {
|
if q.Keyword != "" {
|
||||||
kw := "%" + q.Keyword + "%"
|
kw := "%" + q.Keyword + "%"
|
||||||
@ -121,8 +144,19 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List 公开品牌列表:字母索引只保留「至少有一篇已发布走秀档案」的品牌。
|
||||||
func (r *brandRepository) List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) {
|
func (r *brandRepository) List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) {
|
||||||
scope := r.brandFilterScope(q, restrictIDs)
|
return r.list(ctx, q, restrictIDs, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdmin 后台品牌列表:与 List 同构,但字母索引不要求走秀已发布,
|
||||||
|
// 只有待审 / 驳回走秀的品牌仍能看到(否则它们从后台列表消失,无处审核/编辑)。
|
||||||
|
func (r *brandRepository) ListAdmin(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) {
|
||||||
|
return r.list(ctx, q, restrictIDs, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *brandRepository) list(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32, admin bool) ([]model.Brand, int64, error) {
|
||||||
|
scope := r.brandFilterScope(q, restrictIDs, admin)
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
|
|||||||
@ -51,6 +51,16 @@ func TestPublicReadHidesUnpublishedRunway(t *testing.T) {
|
|||||||
t.Fatalf("后台应读到 pending 行与其图片,实际 rw=%v imgs=%d", rw != nil, len(baseImgs))
|
t.Fatalf("后台应读到 pending 行与其图片,实际 rw=%v imgs=%d", rw != nil, len(baseImgs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 后台详情通路:FindByIDBase 也必须读得到 pending(后台详情页据此渲染;
|
||||||
|
// 若它读视图,后台列表里的待审行点进去就 404)。
|
||||||
|
baseRow, err := repo.FindByIDBase(ctx, idStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("后台 FindByIDBase 应读到 pending 行: %v", err)
|
||||||
|
}
|
||||||
|
if baseRow == nil || baseRow.ID != id {
|
||||||
|
t.Fatalf("后台 FindByIDBase 返回不符: %+v", baseRow)
|
||||||
|
}
|
||||||
|
|
||||||
// 置为已发布后,公开侧才可见。
|
// 置为已发布后,公开侧才可见。
|
||||||
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
|
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
|
||||||
t.Fatalf("置为已发布失败: %v", err)
|
t.Fatalf("置为已发布失败: %v", err)
|
||||||
@ -113,6 +123,15 @@ func TestPublicReadHidesUnpublishedStreet(t *testing.T) {
|
|||||||
t.Fatalf("后台列表应看到 pending 街拍,实际 total=%d", adminTotal)
|
t.Fatalf("后台列表应看到 pending 街拍,实际 total=%d", adminTotal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 后台详情通路:FindByIDBase 也必须读得到 pending(否则后台详情页 404)。
|
||||||
|
baseSnap, err := repo.FindByIDBase(ctx, idStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("后台 FindByIDBase 应读到 pending 行: %v", err)
|
||||||
|
}
|
||||||
|
if baseSnap == nil || baseSnap.ID != id {
|
||||||
|
t.Fatalf("后台 FindByIDBase 返回不符: %+v", baseSnap)
|
||||||
|
}
|
||||||
|
|
||||||
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
|
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
|
||||||
t.Fatalf("置为已发布失败: %v", err)
|
t.Fatalf("置为已发布失败: %v", err)
|
||||||
}
|
}
|
||||||
@ -124,3 +143,59 @@ func TestPublicReadHidesUnpublishedStreet(t *testing.T) {
|
|||||||
t.Fatalf("已发布街拍应出现在公开列表,实际 total=%d", total)
|
t.Fatalf("已发布街拍应出现在公开列表,实际 total=%d", total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasBrandID 判断品牌列表里是否含指定 id(按 id 判定,避免受库里既有同名/同首字母品牌干扰)。
|
||||||
|
func hasBrandID(items []model.Brand, id uint32) bool {
|
||||||
|
for _, it := range items {
|
||||||
|
if it.ID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBrandIndexSplitsPublicAndAdmin 品牌 A-Z 索引的公开/后台分野:
|
||||||
|
// 只有待审走秀的品牌不得出现在公开索引(否则未发布内容从索引泄露),
|
||||||
|
// 但必须出现在后台列表(否则该品牌从后台「消失」,审核员无从下手)。
|
||||||
|
func TestBrandIndexSplitsPublicAndAdmin(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewBrandRepository(db)
|
||||||
|
|
||||||
|
now := uint32(1_800_000_000)
|
||||||
|
brand := model.Brand{NameEn: "ViewTestBrand", NameCn: "视图测试品牌", CreatedAt: now, UpdatedAt: now}
|
||||||
|
if err := db.Create(&brand).Error; err != nil {
|
||||||
|
t.Fatalf("插入品牌失败: %v", err)
|
||||||
|
}
|
||||||
|
rw := model.BrandRunway{
|
||||||
|
BrandID: brand.ID, TitleEn: "view-test-brand", Year: 1995,
|
||||||
|
Status: model.StatusPending, CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := db.Create(&rw).Error; err != nil {
|
||||||
|
t.Fatalf("插入走秀失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM brand_runways WHERE id = ?", rw.ID)
|
||||||
|
db.Exec("DELETE FROM brands WHERE id = ?", brand.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 字母桶 V + 大页,确保命中「必须有走秀档案」的子查询分支、且不会被分页截断。
|
||||||
|
q := dto.BrandQuery{Letter: "V", Page: 1, Size: 400}
|
||||||
|
|
||||||
|
pubItems, _, err := repo.List(ctx, q, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("公开 List 出错: %v", err)
|
||||||
|
}
|
||||||
|
if hasBrandID(pubItems, brand.ID) {
|
||||||
|
t.Fatalf("只有待审走秀的品牌不应出现在公开索引,实际出现在结果里(id=%d)", brand.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
admItems, _, err := repo.ListAdmin(ctx, q, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("后台 ListAdmin 出错: %v", err)
|
||||||
|
}
|
||||||
|
if !hasBrandID(admItems, brand.ID) {
|
||||||
|
t.Fatalf("后台列表应看到只有待审走秀的品牌(id=%d)", brand.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -18,10 +18,15 @@ type StreetSnapRepository interface {
|
|||||||
List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error)
|
List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error)
|
||||||
// ListAdmin 后台管理列表:读基表(含 pending / rejected),与 article_repository.ListAdmin 对称。
|
// ListAdmin 后台管理列表:读基表(含 pending / rejected),与 article_repository.ListAdmin 对称。
|
||||||
ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error)
|
ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error)
|
||||||
// FindByID 查询单条街拍;不存在时返回 ErrNotFound。
|
// FindByID 公开读:查询单条街拍;不存在时返回 ErrNotFound(pending / rejected 视为不存在)。
|
||||||
FindByID(ctx context.Context, id string) (*model.StreetSnap, error)
|
FindByID(ctx context.Context, id string) (*model.StreetSnap, error)
|
||||||
// ListImages 查询某条街拍的全部图片,按排序值升序。
|
// FindByIDBase 后台读基表:与 FindByID 同形,但能看到 pending / rejected。
|
||||||
|
// 后台列表故意展示待审行并链到详情页,详情必须走基表,否则待审项点进去 404。
|
||||||
|
FindByIDBase(ctx context.Context, id string) (*model.StreetSnap, error)
|
||||||
|
// ListImages 公开读:查询某条街拍的全部图片,按排序值升序(未发布图片不可见)。
|
||||||
ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error)
|
ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error)
|
||||||
|
// ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。
|
||||||
|
ListImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error)
|
||||||
// ImagesBySnapIDs 批量查询多条街拍的图片,单次 IN 查询避免 N+1。
|
// ImagesBySnapIDs 批量查询多条街拍的图片,单次 IN 查询避免 N+1。
|
||||||
ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error)
|
ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error)
|
||||||
// Popular 按图片数降序返回热度前 N 个街拍,供 SSG 首页热门街拍使用。
|
// Popular 按图片数降序返回热度前 N 个街拍,供 SSG 首页热门街拍使用。
|
||||||
@ -136,11 +141,18 @@ func (r *streetSnapRepository) ListAdmin(ctx context.Context, q dto.StreetSnapQu
|
|||||||
|
|
||||||
// FindByID 公开读:读公开视图,pending / rejected 街拍视同不存在(返回 ErrNotFound)。
|
// FindByID 公开读:读公开视图,pending / rejected 街拍视同不存在(返回 ErrNotFound)。
|
||||||
func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.StreetSnap, error) {
|
func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.StreetSnap, error) {
|
||||||
|
return r.findByID(r.db.WithContext(ctx).Table(publicStreetSnaps), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByIDBase 后台读基表:不过滤 status(后台详情需渲染待审内容)。
|
||||||
|
func (r *streetSnapRepository) FindByIDBase(ctx context.Context, id string) (*model.StreetSnap, error) {
|
||||||
|
return r.findByID(r.db.WithContext(ctx), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findByID 用给定查询(视图或基表)取单行,公开/后台口径只在数据来源上不同。
|
||||||
|
func (r *streetSnapRepository) findByID(q *gorm.DB, id string) (*model.StreetSnap, error) {
|
||||||
var snap model.StreetSnap
|
var snap model.StreetSnap
|
||||||
err := r.db.WithContext(ctx).
|
err := q.Where("id = ? AND is_deleted = 0", id).First(&snap).Error
|
||||||
Table(publicStreetSnaps).
|
|
||||||
Where("id = ? AND is_deleted = 0", id).
|
|
||||||
First(&snap).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@ -161,8 +173,8 @@ func (r *streetSnapRepository) ListImages(ctx context.Context, snapID string) ([
|
|||||||
return imgs, err
|
return imgs, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// listImagesBase 读基表(含未发布),仅供后台 GetForEdit 使用 —— 否则编辑待审内容时看不到图。
|
// ListImagesBase 读基表(含未发布),供后台 GetForEdit / 后台详情使用 —— 否则待审内容看不到图。
|
||||||
func (r *streetSnapRepository) listImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) {
|
func (r *streetSnapRepository) ListImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) {
|
||||||
var imgs []model.StreetSnapImage
|
var imgs []model.StreetSnapImage
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Where("snap_id = ? AND is_deleted = 0", snapID).
|
Where("snap_id = ? AND is_deleted = 0", snapID).
|
||||||
@ -226,7 +238,7 @@ func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*mode
|
|||||||
}
|
}
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
imgs, err := r.listImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
|
imgs, err := r.ListImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,6 +50,11 @@ type fakeBrand struct{}
|
|||||||
func (f *fakeBrand) List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
|
func (f *fakeBrand) List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
|
||||||
return []dto.PublicBrand{{UID: "001DESke", NameEn: "Acne Studios", NameCn: "艾克妮"}}, 1, nil
|
return []dto.PublicBrand{{UID: "001DESke", NameEn: "Acne Studios", NameCn: "艾克妮"}}, 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminList 后台品牌列表:假实现里与 List 同源(本假实现不区分走秀发布态)。
|
||||||
|
func (f *fakeBrand) AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
|
||||||
|
return f.List(ctx, q)
|
||||||
|
}
|
||||||
func (f *fakeBrand) Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error) {
|
func (f *fakeBrand) Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@ -104,11 +109,19 @@ func (f *fakeArticle) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.
|
|||||||
ImageCount: 12,
|
ImageCount: 12,
|
||||||
}}, 1, nil
|
}}, 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detail 公开详情。本 fake 代表一条「待审」内容:公开路径读视图,读不到,
|
||||||
|
// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。
|
||||||
func (f *fakeArticle) Detail(ctx context.Context, id, locale string) (*dto.PublicArticleDetail, error) {
|
func (f *fakeArticle) Detail(ctx context.Context, id, locale string) (*dto.PublicArticleDetail, error) {
|
||||||
|
return nil, service.ErrArticleNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。
|
||||||
|
func (f *fakeArticle) DetailAdmin(ctx context.Context, id, locale string) (*dto.PublicArticleDetail, error) {
|
||||||
return &dto.PublicArticleDetail{
|
return &dto.PublicArticleDetail{
|
||||||
UID: id,
|
UID: id,
|
||||||
BrandUID: "001DESke",
|
BrandUID: "001DESke",
|
||||||
Title: "Acne Studios Spring 2024",
|
Title: "Acne Studios Spring 2024(待审)",
|
||||||
BrandName: "Acne Studios",
|
BrandName: "Acne Studios",
|
||||||
Images: []dto.PublicArticleImage{
|
Images: []dto.PublicArticleImage{
|
||||||
{ID: "i1", Image: "/uploads/runway/1.jpg", Name: "Look 1"},
|
{ID: "i1", Image: "/uploads/runway/1.jpg", Name: "Look 1"},
|
||||||
@ -149,10 +162,17 @@ func (f *fakeStreet) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dt
|
|||||||
return f.List(ctx, q)
|
return f.List(ctx, q)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detail 公开详情。本 fake 代表一条「待审」街拍:公开路径读视图,读不到,
|
||||||
|
// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。
|
||||||
func (f *fakeStreet) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
func (f *fakeStreet) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||||
|
return nil, service.ErrStreetSnapNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。
|
||||||
|
func (f *fakeStreet) DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||||
return &dto.PublicStreetSnapDetail{
|
return &dto.PublicStreetSnapDetail{
|
||||||
UID: id,
|
UID: id,
|
||||||
Title: "Paris Fashion Week Street",
|
Title: "Paris Fashion Week Street(待审)",
|
||||||
Images: []dto.PublicArticleImage{
|
Images: []dto.PublicArticleImage{
|
||||||
{ID: "j1", Image: "/uploads/snap/1.jpg", Name: "Snap 1"},
|
{ID: "j1", Image: "/uploads/snap/1.jpg", Name: "Snap 1"},
|
||||||
},
|
},
|
||||||
@ -270,13 +290,15 @@ func TestBackstage(t *testing.T) {
|
|||||||
t.Fatalf("走秀列表异常: %d %s", w.Code, w.Body.String())
|
t.Fatalf("走秀列表异常: %d %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7) 走秀详情渲染图集
|
// 7) 后台走秀详情渲染图集。
|
||||||
|
// fakeArticle.Detail(公开路径)对这条「待审」内容固定返回 not found,
|
||||||
|
// 这里仍 200 且渲染出「待审」标记,即证明后台详情走的是 DetailAdmin(读基表)。
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
req = httptest.NewRequest(http.MethodGet, "/admin/runways/r001DESke", nil)
|
req = httptest.NewRequest(http.MethodGet, "/admin/runways/r001DESke", nil)
|
||||||
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
|
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
if w.Code != 200 || !strings.Contains(w.Body.String(), "Look 1") {
|
if w.Code != 200 || !strings.Contains(w.Body.String(), "Look 1") || !strings.Contains(w.Body.String(), "(待审)") {
|
||||||
t.Fatalf("走秀详情异常: %d %s", w.Code, w.Body.String())
|
t.Fatalf("后台应能渲染待审走秀详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8) 街拍列表渲染
|
// 8) 街拍列表渲染
|
||||||
@ -288,13 +310,14 @@ func TestBackstage(t *testing.T) {
|
|||||||
t.Fatalf("街拍列表异常: %d %s", w.Code, w.Body.String())
|
t.Fatalf("街拍列表异常: %d %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9) 街拍详情渲染图集
|
// 9) 后台街拍详情渲染图集。
|
||||||
|
// 同 7):fakeStreet.Detail(公开路径)返回 not found,此处 200 且渲染待审标记即证明走 DetailAdmin。
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps/s001DESke", nil)
|
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps/s001DESke", nil)
|
||||||
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
|
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
if w.Code != 200 || !strings.Contains(w.Body.String(), "Snap 1") {
|
if w.Code != 200 || !strings.Contains(w.Body.String(), "Snap 1") || !strings.Contains(w.Body.String(), "(待审)") {
|
||||||
t.Fatalf("街拍详情异常: %d %s", w.Code, w.Body.String())
|
t.Fatalf("后台应能渲染待审街拍详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,8 @@ type MediaCleanupEnqueuer interface {
|
|||||||
type ArticleService interface {
|
type ArticleService interface {
|
||||||
List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error)
|
List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error)
|
||||||
Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error)
|
Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error)
|
||||||
|
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
|
||||||
|
DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error)
|
||||||
// AdminList 后台管理列表:返回含季节 / 年份 / 系列等管理字段的走秀项。
|
// AdminList 后台管理列表:返回含季节 / 年份 / 系列等管理字段的走秀项。
|
||||||
AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
|
AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
|
||||||
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
|
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
|
||||||
@ -151,22 +153,46 @@ func excludeCover(imgs []dto.PublicArticleImage, cover string) []dto.PublicArtic
|
|||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言;
|
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrArticleNotFound)。
|
||||||
// 图片统一走展示样式(VIP 与免费同质量)。
|
// locale 决定标题/描述/品牌名的展示语言;图片统一走展示样式(VIP 与免费同质量)。
|
||||||
func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
|
func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
|
||||||
row, err := s.articles.FindByID(ctx, id)
|
row, err := s.articles.FindByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, repository.ErrNotFound) {
|
return nil, s.detailErr(err)
|
||||||
return nil, ErrArticleNotFound
|
|
||||||
}
|
}
|
||||||
return nil, NewError(http.StatusInternalServerError, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
imgs, err := s.articles.ListImages(ctx, id)
|
imgs, err := s.articles.ListImages(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalErr(err.Error())
|
return nil, internalErr(err.Error())
|
||||||
}
|
}
|
||||||
|
return s.buildDetail(row, imgs, locale), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
|
||||||
|
//
|
||||||
|
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
|
||||||
|
// 若后台详情也走公开视图,待审项会 404、审核无从下手。
|
||||||
|
func (s *articleService) DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
|
||||||
|
row, err := s.articles.FindByIDBase(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, s.detailErr(err)
|
||||||
|
}
|
||||||
|
imgs, err := s.articles.ListImagesBase(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr(err.Error())
|
||||||
|
}
|
||||||
|
return s.buildDetail(row, imgs, locale), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。
|
||||||
|
func (s *articleService) detailErr(err error) error {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
return ErrArticleNotFound
|
||||||
|
}
|
||||||
|
return NewError(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDetail 把查询行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
|
||||||
|
func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRunwayImage, locale string) *dto.PublicArticleDetail {
|
||||||
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 look_index
|
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 look_index
|
||||||
// 直接挂到所属主图的 Detail 子数组下 —— 「look → 细节」的层级与前端 UI 一致,
|
// 直接挂到所属主图的 Detail 子数组下 —— 「look → 细节」的层级与前端 UI 一致,
|
||||||
// 前端读 img.detail 即可,无需再单独维护一份「按 look_index 分组」的映射表。
|
// 前端读 img.detail 即可,无需再单独维护一份「按 look_index 分组」的映射表。
|
||||||
@ -198,7 +224,7 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string) (
|
|||||||
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
||||||
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
|
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
|
||||||
Images: pubMains,
|
Images: pubMains,
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -16,6 +16,9 @@ import (
|
|||||||
// BrandService 品牌业务接口。
|
// BrandService 品牌业务接口。
|
||||||
type BrandService interface {
|
type BrandService interface {
|
||||||
List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error)
|
List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error)
|
||||||
|
// AdminList 后台品牌列表:字母索引不要求走秀已发布,
|
||||||
|
// 使只有待审 / 驳回走秀的品牌仍出现在后台(公开 List 读视图,会挡掉它们)。
|
||||||
|
AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error)
|
||||||
// Hot 返回热门品牌(按走秀图片数排名前 limit 个)并附带各品牌档案数。
|
// Hot 返回热门品牌(按走秀图片数排名前 limit 个)并附带各品牌档案数。
|
||||||
// 供 SSG 构建期(RunwayLooks 侧栏 / 品牌弹窗 HOT 标签)一次性取前 N 个热门品牌;
|
// 供 SSG 构建期(RunwayLooks 侧栏 / 品牌弹窗 HOT 标签)一次性取前 N 个热门品牌;
|
||||||
// 复用 List 的 featured 收窄逻辑(FeaturedIDs("images", limit)),与首页精选口径一致。
|
// 复用 List 的 featured 收窄逻辑(FeaturedIDs("images", limit)),与首页精选口径一致。
|
||||||
@ -70,6 +73,18 @@ func (s *brandService) List(ctx context.Context, q dto.BrandQuery) ([]dto.Public
|
|||||||
return s.toPublicBrands(items, q.Locale), total, nil
|
return s.toPublicBrands(items, q.Locale), total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminList 后台品牌列表:与 List 同形,但走 ListAdmin —— 字母索引不过滤走秀发布态,
|
||||||
|
// 只有待审 / 驳回走秀的品牌仍可见。后台不做 featured 收窄,故 restrictIDs 传 nil。
|
||||||
|
func (s *brandService) AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
|
||||||
|
q.Normalize()
|
||||||
|
|
||||||
|
items, total, err := s.brands.ListAdmin(ctx, q, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, internalErr(err.Error())
|
||||||
|
}
|
||||||
|
return s.toPublicBrands(items, q.Locale), total, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Hot 返回热门品牌:按走秀图片总数排名取前 limit 个,结果按热度顺序(而非字母序)排列。
|
// Hot 返回热门品牌:按走秀图片总数排名取前 limit 个,结果按热度顺序(而非字母序)排列。
|
||||||
//
|
//
|
||||||
// 供 SSG 构建期(走秀页侧栏品牌 filter / 品牌弹窗 HOT 标签)一次性取用——
|
// 供 SSG 构建期(走秀页侧栏品牌 filter / 品牌弹窗 HOT 标签)一次性取用——
|
||||||
|
|||||||
@ -20,6 +20,8 @@ type StreetSnapService interface {
|
|||||||
// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。
|
// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。
|
||||||
AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
||||||
Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
|
Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
|
||||||
|
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
|
||||||
|
DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
|
||||||
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
||||||
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
|
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
|
||||||
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
||||||
@ -144,27 +146,52 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA
|
|||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detail 查询街拍详情,含完整图片集。图片统一走展示样式(VIP 与免费同质量)。
|
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。
|
||||||
|
// 图片统一走展示样式(VIP 与免费同质量)。
|
||||||
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||||
snap, err := s.repo.FindByID(ctx, id)
|
snap, err := s.repo.FindByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, repository.ErrNotFound) {
|
return nil, s.detailErr(err)
|
||||||
return nil, ErrStreetSnapNotFound
|
|
||||||
}
|
}
|
||||||
return nil, internalErr(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
imgs, err := s.repo.ListImages(ctx, id)
|
imgs, err := s.repo.ListImages(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalErr(err.Error())
|
return nil, internalErr(err.Error())
|
||||||
}
|
}
|
||||||
|
return s.buildSnapDetail(snap, imgs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
|
||||||
|
//
|
||||||
|
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
|
||||||
|
// 若后台详情也走公开视图,待审项会 404、审核无从下手。
|
||||||
|
func (s *streetSnapService) DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||||
|
snap, err := s.repo.FindByIDBase(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, s.detailErr(err)
|
||||||
|
}
|
||||||
|
imgs, err := s.repo.ListImagesBase(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr(err.Error())
|
||||||
|
}
|
||||||
|
return s.buildSnapDetail(snap, imgs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。
|
||||||
|
func (s *streetSnapService) detailErr(err error) error {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
return ErrStreetSnapNotFound
|
||||||
|
}
|
||||||
|
return internalErr(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSnapDetail 把街拍行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
|
||||||
|
func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model.StreetSnapImage) *dto.PublicStreetSnapDetail {
|
||||||
return &dto.PublicStreetSnapDetail{
|
return &dto.PublicStreetSnapDetail{
|
||||||
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
|
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
|
||||||
Title: snap.Title,
|
Title: snap.Title,
|
||||||
Cover: s.img.Compose(snap.Cover),
|
Cover: s.img.Compose(snap.Cover),
|
||||||
Images: s.buildSnapImages(imgs),
|
Images: s.buildSnapImages(imgs),
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):
|
// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):
|
||||||
|
|||||||
Reference in New Issue
Block a user