refactor(publish): 公开读改走只读视图,后台仍读基表
This commit is contained in:
@ -242,7 +242,7 @@ func (h *BackstageHandler) RunwayDetail(c *gin.Context) {
|
|||||||
renderPage(c, http.StatusOK, "runway-detail", gin.H{"Article": art})
|
renderPage(c, http.StatusOK, "runway-detail", gin.H{"Article": art})
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreetSnaps 街拍管理列表:按年份/城市/排序筛选,复用 StreetSnapService.List。
|
// StreetSnaps 街拍管理列表:按年份/城市/排序筛选,复用 StreetSnapService.AdminList(读基表,含待审)。
|
||||||
func (h *BackstageHandler) StreetSnaps(c *gin.Context) {
|
func (h *BackstageHandler) StreetSnaps(c *gin.Context) {
|
||||||
q := dto.StreetSnapQuery{
|
q := dto.StreetSnapQuery{
|
||||||
Year: atoiDefault(c.Query("year"), 0),
|
Year: atoiDefault(c.Query("year"), 0),
|
||||||
@ -251,7 +251,7 @@ func (h *BackstageHandler) StreetSnaps(c *gin.Context) {
|
|||||||
Page: atoiDefault(c.Query("page"), 1),
|
Page: atoiDefault(c.Query("page"), 1),
|
||||||
Locale: "en",
|
Locale: "en",
|
||||||
}
|
}
|
||||||
items, total, err := h.street.List(c.Request.Context(), q)
|
items, total, err := h.street.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
|
||||||
|
|||||||
@ -142,7 +142,7 @@ func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]mod
|
|||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunway{}).
|
Table(publicRunways).
|
||||||
Scopes(scope).
|
Scopes(scope).
|
||||||
Count(&total).Error; err != nil {
|
Count(&total).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
@ -153,7 +153,7 @@ func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]mod
|
|||||||
|
|
||||||
var rows []model.RunwayRow
|
var rows []model.RunwayRow
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunway{}).
|
Table(publicRunways).
|
||||||
Scopes(scope).
|
Scopes(scope).
|
||||||
Select(articleListColumns).
|
Select(articleListColumns).
|
||||||
Joins(brandJoin).
|
Joins(brandJoin).
|
||||||
@ -199,7 +199,7 @@ func (r *articleRepository) ListAdmin(ctx context.Context, q dto.ArticleQuery) (
|
|||||||
func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) {
|
func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) {
|
||||||
var row model.RunwayRow
|
var row model.RunwayRow
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunway{}).
|
Table(publicRunways).
|
||||||
Select(articleDetailColumns).
|
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).
|
||||||
@ -213,7 +213,19 @@ func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.Run
|
|||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListImages 公开读:读公开视图,未发布走秀的图片不可见(视图已含 is_deleted 过滤)。
|
||||||
func (r *articleRepository) ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
|
func (r *articleRepository) ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
|
||||||
|
var imgs []model.BrandRunwayImage
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table(publicRunwayImages).
|
||||||
|
Where("runway_id = ?", runwayID).
|
||||||
|
Order("sort_order ASC, id ASC").
|
||||||
|
Find(&imgs).Error
|
||||||
|
return imgs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// listImagesBase 读基表(含未发布),仅供后台 GetForEdit 使用 —— 否则编辑待审内容时看不到图。
|
||||||
|
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).
|
||||||
@ -230,8 +242,9 @@ func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32)
|
|||||||
|
|
||||||
var imgs []model.BrandRunwayImage
|
var imgs []model.BrandRunwayImage
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
|
Table(publicRunwayImages).
|
||||||
Select("runway_id, image, name, sort_order").
|
Select("runway_id, image, name, sort_order").
|
||||||
Where("runway_id IN ? AND is_deleted = 0 AND is_detail = 0", ids).
|
Where("runway_id IN ? AND is_detail = 0", ids).
|
||||||
Order("sort_order ASC, id ASC").
|
Order("sort_order ASC, id ASC").
|
||||||
Find(&imgs).Error
|
Find(&imgs).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -265,7 +278,7 @@ func (r *articleRepository) GetForEdit(ctx context.Context, id uint32) (*model.B
|
|||||||
}
|
}
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
imgs, err := r.ListImages(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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,12 +71,12 @@ 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 索引里。
|
||||||
func (r *brandRepository) hasArticlesSubQuery() *gorm.DB {
|
func (r *brandRepository) hasArticlesSubQuery() *gorm.DB {
|
||||||
return r.db.Model(&model.BrandRunway{}).
|
return r.db.Table(publicRunways).
|
||||||
Select("DISTINCT brand_id").
|
Select("DISTINCT brand_id")
|
||||||
Where("is_deleted = 0")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// brandFilterScope 把品牌查询条件编译为 GORM Scope。
|
// brandFilterScope 把品牌查询条件编译为 GORM Scope。
|
||||||
@ -157,10 +157,10 @@ func (r *brandRepository) FeaturedIDs(ctx context.Context, metric string, limit
|
|||||||
var agg []struct {
|
var agg []struct {
|
||||||
BrandID uint32 `gorm:"column:brand_id"`
|
BrandID uint32 `gorm:"column:brand_id"`
|
||||||
}
|
}
|
||||||
|
// 读公开视图:热门榜只能由已发布走秀构成,避免把待审/驳回内容的图片数计入热度。
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunway{}).
|
Table(publicRunways).
|
||||||
Select("brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows").
|
Select("brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows").
|
||||||
Where("is_deleted = 0").
|
|
||||||
Group("brand_id").
|
Group("brand_id").
|
||||||
Order(orderCol + " DESC").
|
Order(orderCol + " DESC").
|
||||||
Limit(limit).
|
Limit(limit).
|
||||||
@ -281,7 +281,7 @@ WITH latest_per_brand AS (
|
|||||||
END DESC,
|
END DESC,
|
||||||
id DESC
|
id DESC
|
||||||
) AS rn
|
) AS rn
|
||||||
FROM brand_runways
|
FROM public_brand_runways brand_runways
|
||||||
WHERE is_deleted = 0 AND brand_id IN (?)
|
WHERE is_deleted = 0 AND brand_id IN (?)
|
||||||
)
|
)
|
||||||
SELECT id, brand_id, cover, title_en, title_cn
|
SELECT id, brand_id, cover, title_en, title_cn
|
||||||
|
|||||||
126
internal/repository/public_read_integration_test.go
Normal file
126
internal/repository/public_read_integration_test.go
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// 集成测试:公开仓储方法必须读视图(读不到 pending),后台方法必须读基表(读得到 pending)。
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fashionapi/internal/dto"
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPublicReadHidesUnpublishedRunway 走秀公开列表/详情/图片看不到 pending;后台 GetForEdit 看得到。
|
||||||
|
func TestPublicReadHidesUnpublishedRunway(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewArticleRepository(db)
|
||||||
|
|
||||||
|
const year = 1997
|
||||||
|
id := seedRunway(t, db, model.StatusPending, year)
|
||||||
|
idStr := strconv.FormatUint(uint64(id), 10)
|
||||||
|
|
||||||
|
if _, err := repo.FindByID(ctx, idStr); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("pending 走秀详情应 ErrNotFound,实际 %v", err)
|
||||||
|
}
|
||||||
|
imgs, err := repo.ListImages(ctx, idStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListImages 出错: %v", err)
|
||||||
|
}
|
||||||
|
if len(imgs) != 0 {
|
||||||
|
t.Fatalf("pending 走秀的图片不应公开,实际 %d 张", len(imgs))
|
||||||
|
}
|
||||||
|
_, total, err := repo.List(ctx, dto.ArticleQuery{Year: year, Size: 20})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List 出错: %v", err)
|
||||||
|
}
|
||||||
|
if total != 0 {
|
||||||
|
t.Fatalf("pending 走秀不应出现在公开列表,实际 total=%d", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台读基表:必须能看到这条待审记录与其图片,否则审核页是空的。
|
||||||
|
rw, baseImgs, err := repo.GetForEdit(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("后台 GetForEdit 应读到 pending 行: %v", err)
|
||||||
|
}
|
||||||
|
if rw == nil || len(baseImgs) == 0 {
|
||||||
|
t.Fatalf("后台应读到 pending 行与其图片,实际 rw=%v imgs=%d", rw != nil, len(baseImgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 置为已发布后,公开侧才可见。
|
||||||
|
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
|
||||||
|
t.Fatalf("置为已发布失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.FindByID(ctx, idStr); err != nil {
|
||||||
|
t.Fatalf("已发布走秀详情应可读: %v", err)
|
||||||
|
}
|
||||||
|
_, total, err = repo.List(ctx, dto.ArticleQuery{Year: year, Size: 20})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List 出错: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 {
|
||||||
|
t.Fatalf("已发布走秀应出现在公开列表,实际 total=%d", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPublicReadHidesUnpublishedStreet 街拍公开列表/详情/图片看不到 pending;后台 GetForEdit 看得到。
|
||||||
|
func TestPublicReadHidesUnpublishedStreet(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewStreetSnapRepository(db)
|
||||||
|
|
||||||
|
const year = 1996
|
||||||
|
id := seedStreetSnap(t, db, model.StatusPending, year)
|
||||||
|
idStr := strconv.FormatUint(uint64(id), 10)
|
||||||
|
|
||||||
|
if _, err := repo.FindByID(ctx, idStr); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("pending 街拍详情应 ErrNotFound,实际 %v", err)
|
||||||
|
}
|
||||||
|
imgs, err := repo.ListImages(ctx, idStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListImages 出错: %v", err)
|
||||||
|
}
|
||||||
|
if len(imgs) != 0 {
|
||||||
|
t.Fatalf("pending 街拍的图片不应公开,实际 %d 张", len(imgs))
|
||||||
|
}
|
||||||
|
_, total, err := repo.List(ctx, dto.StreetSnapQuery{City: "ViewTestCity", Size: 20})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List 出错: %v", err)
|
||||||
|
}
|
||||||
|
if total != 0 {
|
||||||
|
t.Fatalf("pending 街拍不应出现在公开列表,实际 total=%d", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
snap, baseImgs, err := repo.GetForEdit(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("后台 GetForEdit 应读到 pending 行: %v", err)
|
||||||
|
}
|
||||||
|
if snap == nil || len(baseImgs) == 0 {
|
||||||
|
t.Fatalf("后台应读到 pending 行与其图片,实际 snap=%v imgs=%d", snap != nil, len(baseImgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台列表 ListAdmin 读基表:必须能看到这条待审记录,否则审核员无从下手。
|
||||||
|
_, adminTotal, err := repo.ListAdmin(ctx, dto.StreetSnapQuery{City: "ViewTestCity", Size: 20})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAdmin 出错: %v", err)
|
||||||
|
}
|
||||||
|
if adminTotal != 1 {
|
||||||
|
t.Fatalf("后台列表应看到 pending 街拍,实际 total=%d", adminTotal)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
|
||||||
|
t.Fatalf("置为已发布失败: %v", err)
|
||||||
|
}
|
||||||
|
_, total, err = repo.List(ctx, dto.StreetSnapQuery{City: "ViewTestCity", Size: 20})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List 出错: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 {
|
||||||
|
t.Fatalf("已发布街拍应出现在公开列表,实际 total=%d", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
internal/repository/public_views.go
Normal file
19
internal/repository/public_views.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
// 公开只读视图的表名。
|
||||||
|
//
|
||||||
|
// 把「已发布且未删除」的条件固化在视图里(见 db/migrations/2026-09-22-01-single-table-publish.sql),
|
||||||
|
// 因此公开查询**不可能**漏写可见性过滤 —— 这是单表发布模型相对「每处记得加 status」的核心优势。
|
||||||
|
//
|
||||||
|
// publicRunways 带 `AS brand_runways` 别名:article_repository 里既有的查询大量使用
|
||||||
|
// `brand_runways.xxx` 限定列与 JOIN 条件,起同名别名后这些 SQL 一行都不用改。
|
||||||
|
//
|
||||||
|
// publicStreetSnaps 同样带 `AS street_snaps` 别名:street_snaps 的排序子句 snapOrderBy
|
||||||
|
// 使用 `street_snaps.xxx` 限定列,不带别名会报 missing FROM-clause entry。
|
||||||
|
// 后台读基表时**不要**使用这些常量。
|
||||||
|
const (
|
||||||
|
publicRunways = "public_brand_runways AS brand_runways"
|
||||||
|
publicRunwayImages = "public_brand_runway_images"
|
||||||
|
publicStreetSnaps = "public_street_snaps AS street_snaps"
|
||||||
|
publicStreetSnapImages = "public_street_snap_images"
|
||||||
|
)
|
||||||
@ -16,6 +16,8 @@ import (
|
|||||||
type StreetSnapRepository interface {
|
type StreetSnapRepository interface {
|
||||||
// List 按筛选条件分页查询街拍,同时返回符合条件的总数。
|
// List 按筛选条件分页查询街拍,同时返回符合条件的总数。
|
||||||
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(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error)
|
||||||
// FindByID 查询单条街拍;不存在时返回 ErrNotFound。
|
// FindByID 查询单条街拍;不存在时返回 ErrNotFound。
|
||||||
FindByID(ctx context.Context, id string) (*model.StreetSnap, error)
|
FindByID(ctx context.Context, id string) (*model.StreetSnap, error)
|
||||||
// ListImages 查询某条街拍的全部图片,按排序值升序。
|
// ListImages 查询某条街拍的全部图片,按排序值升序。
|
||||||
@ -75,9 +77,39 @@ func snapOrderBy(sort string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List 公开读:读公开视图,pending / rejected 街拍不可见。
|
||||||
func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
|
func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
|
||||||
scope := snapFilterScope(q)
|
scope := snapFilterScope(q)
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Table(publicStreetSnaps).
|
||||||
|
Scopes(scope).
|
||||||
|
Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return []model.StreetSnap{}, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []model.StreetSnap
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Table(publicStreetSnaps).
|
||||||
|
Scopes(scope).
|
||||||
|
Order(snapOrderBy(q.Sort)).
|
||||||
|
Offset(q.Offset()).
|
||||||
|
Limit(q.Size).
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return rows, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdmin 后台管理列表:与 List 同构,但读基表 —— 后台必须看得到 pending / rejected,
|
||||||
|
// 否则审核员在列表页看不到待审内容(公开 List 读视图,会把这些行挡掉)。
|
||||||
|
func (r *streetSnapRepository) ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
|
||||||
|
scope := snapFilterScope(q)
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
Model(&model.StreetSnap{}).
|
Model(&model.StreetSnap{}).
|
||||||
@ -102,9 +134,11 @@ func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery)
|
|||||||
return rows, total, nil
|
return rows, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
var snap model.StreetSnap
|
var snap model.StreetSnap
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
|
Table(publicStreetSnaps).
|
||||||
Where("id = ? AND is_deleted = 0", id).
|
Where("id = ? AND is_deleted = 0", id).
|
||||||
First(&snap).Error
|
First(&snap).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -116,7 +150,19 @@ func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.
|
|||||||
return &snap, nil
|
return &snap, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListImages 公开读:读公开视图,未发布街拍的图片不可见(视图已含 is_deleted 过滤)。
|
||||||
func (r *streetSnapRepository) ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) {
|
func (r *streetSnapRepository) ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) {
|
||||||
|
var imgs []model.StreetSnapImage
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table(publicStreetSnapImages).
|
||||||
|
Where("snap_id = ?", snapID).
|
||||||
|
Order("sort_order ASC, id ASC").
|
||||||
|
Find(&imgs).Error
|
||||||
|
return imgs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// listImagesBase 读基表(含未发布),仅供后台 GetForEdit 使用 —— 否则编辑待审内容时看不到图。
|
||||||
|
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).
|
||||||
@ -133,8 +179,9 @@ func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32
|
|||||||
|
|
||||||
var imgs []model.StreetSnapImage
|
var imgs []model.StreetSnapImage
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
|
Table(publicStreetSnapImages).
|
||||||
Select("snap_id, image, name, sort_order").
|
Select("snap_id, image, name, sort_order").
|
||||||
Where("snap_id IN ? AND is_deleted = 0", ids).
|
Where("snap_id IN ?", ids).
|
||||||
Order("sort_order ASC, id ASC").
|
Order("sort_order ASC, id ASC").
|
||||||
Find(&imgs).Error
|
Find(&imgs).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -151,6 +198,7 @@ func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32
|
|||||||
func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.StreetSnap, error) {
|
func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.StreetSnap, error) {
|
||||||
var rows []model.StreetSnap
|
var rows []model.StreetSnap
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
|
Table(publicStreetSnaps).
|
||||||
Where("is_deleted = 0").
|
Where("is_deleted = 0").
|
||||||
Order("image_count DESC, id DESC").
|
Order("image_count DESC, id DESC").
|
||||||
Limit(limit).
|
Limit(limit).
|
||||||
@ -178,7 +226,7 @@ func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*mode
|
|||||||
}
|
}
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
imgs, err := r.ListImages(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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -143,6 +143,12 @@ func (f *fakeStreet) List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.Pub
|
|||||||
ImageCount: 8,
|
ImageCount: 8,
|
||||||
}}, 1, nil
|
}}, 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminList 后台列表读基表;假实现里与 List 同源即可(本假实现不区分可见性)。
|
||||||
|
func (f *fakeStreet) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) {
|
||||||
|
return f.List(ctx, q)
|
||||||
|
}
|
||||||
|
|
||||||
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 &dto.PublicStreetSnapDetail{
|
return &dto.PublicStreetSnapDetail{
|
||||||
UID: id,
|
UID: id,
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import (
|
|||||||
// StreetSnapService 街拍业务接口。
|
// StreetSnapService 街拍业务接口。
|
||||||
type StreetSnapService interface {
|
type StreetSnapService interface {
|
||||||
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
||||||
|
// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。
|
||||||
|
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)
|
||||||
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
||||||
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
|
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
|
||||||
@ -62,17 +64,11 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
|
|||||||
return nil, 0, internalErr(err.Error())
|
return nil, 0, internalErr(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
list := make([]dto.PublicStreetSnap, 0, len(rows))
|
|
||||||
ids := make([]uint32, 0, len(rows))
|
ids := make([]uint32, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
ids = append(ids, r.ID)
|
ids = append(ids, r.ID)
|
||||||
list = append(list, dto.PublicStreetSnap{
|
|
||||||
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
|
|
||||||
Title: r.Title,
|
|
||||||
Cover: s.img.ComposeThumb(r.Cover), // 列表封面走缩略图样式(小尺寸省流量)
|
|
||||||
ImageCount: r.ImageCount,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
list := s.toPublicSnaps(rows)
|
||||||
|
|
||||||
if q.Images > 0 && len(list) > 0 {
|
if q.Images > 0 && len(list) > 0 {
|
||||||
if err := s.attachImages(ctx, list, ids, q.Images); err != nil {
|
if err := s.attachImages(ctx, list, ids, q.Images); err != nil {
|
||||||
@ -82,6 +78,35 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
|
|||||||
return list, total, nil
|
return list, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminList 后台街拍列表:读基表,包含 pending / rejected。
|
||||||
|
//
|
||||||
|
// 与 List 用同一个映射(toPublicSnaps),但列表不附图——后台列表只展示卡片字段,
|
||||||
|
// 与走秀后台 AdminList 的口径一致。
|
||||||
|
func (s *streetSnapService) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) {
|
||||||
|
q.Normalize()
|
||||||
|
|
||||||
|
rows, total, err := s.repo.ListAdmin(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, internalErr(err.Error())
|
||||||
|
}
|
||||||
|
return s.toPublicSnaps(rows), total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。
|
||||||
|
// 公开 List 与后台 AdminList 共用,避免两处重复维护映射(封面统一走缩略图样式,小尺寸省流量)。
|
||||||
|
func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap) []dto.PublicStreetSnap {
|
||||||
|
list := make([]dto.PublicStreetSnap, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
list = append(list, dto.PublicStreetSnap{
|
||||||
|
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
|
||||||
|
Title: r.Title,
|
||||||
|
Cover: s.img.ComposeThumb(r.Cover),
|
||||||
|
ImageCount: r.ImageCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
// attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐条请求详情。
|
// attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐条请求详情。
|
||||||
func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicStreetSnap, ids []uint32, limit int) error {
|
func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicStreetSnap, ids []uint32, limit int) error {
|
||||||
grouped, err := s.repo.ImagesBySnapIDs(ctx, ids)
|
grouped, err := s.repo.ImagesBySnapIDs(ctx, ids)
|
||||||
|
|||||||
Reference in New Issue
Block a user