update
This commit is contained in:
@ -56,7 +56,9 @@ func (q BrandQuery) Offset() int { return (q.Page - 1) * q.Size }
|
||||
// 注意:对外只暴露编码后的 UID(非自增主键),防止顺序枚举。
|
||||
type PublicBrand struct {
|
||||
UID string `json:"id"` // 编码后的品牌 id(无序串),对外唯一标识
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"` // 按 locale 选列(cn→name_cn,en→name_en)
|
||||
NameEn string `json:"name_en"` // 英文规范名(始终返回,供前端组合双语展示)
|
||||
NameCn string `json:"name_cn"` // 中文名(可能为空,供前端组合双语展示)
|
||||
ShowName string `json:"show_name"`
|
||||
ArticleCount int `json:"article_count"` // 该品牌下的走秀档案数,用于索引计数
|
||||
}
|
||||
|
||||
@ -80,10 +80,4 @@ func (h *AuthHandler) Me(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"user": user})
|
||||
}
|
||||
|
||||
// Logout 登出。
|
||||
//
|
||||
// POST /api/auth/logout
|
||||
// 无状态 JWT 服务端无需作废令牌,由客户端丢弃即可;保留接口以便将来扩展黑名单。
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
|
||||
@ -2,9 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/pkg/response"
|
||||
"fashionapi/internal/service"
|
||||
|
||||
@ -19,27 +17,40 @@ import (
|
||||
// - 复用现有 service 层逻辑,与对外公开接口保持同一份数据源与映射规则,
|
||||
// 不会出现「构建接口与线上接口返回结构不一致」的坑。
|
||||
type SSGHandler struct {
|
||||
brands service.BrandService
|
||||
articles service.ArticleService
|
||||
brands service.BrandService
|
||||
index service.IndexService
|
||||
}
|
||||
|
||||
// NewSSGHandler 创建 SSG handler。
|
||||
func NewSSGHandler(brands service.BrandService, articles service.ArticleService) *SSGHandler {
|
||||
return &SSGHandler{brands: brands, articles: articles}
|
||||
func NewSSGHandler(brands service.BrandService, index service.IndexService) *SSGHandler {
|
||||
return &SSGHandler{brands: brands, index: index}
|
||||
}
|
||||
|
||||
// Popular 首页「热门品牌」区块数据(SSG 内部版)。
|
||||
// IndexRunway 首页(/index/)runway 区块数据。
|
||||
//
|
||||
// GET /api/ssg/brands/popular?limit=20
|
||||
// GET /api/v1/ssg/index/runway
|
||||
//
|
||||
// 与公开版逻辑完全一致(复用 service.BrandService.Popular),只是挪到了内部端口,
|
||||
// 不再挂在对外公开的 /api/public 下。limit 默认 20、上限 100。
|
||||
func (h *SSGHandler) Popular(c *gin.Context) {
|
||||
limit := queryInt(c, "limit", 20)
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 20
|
||||
// 数据由各模块在服务端写死组合(不接收任何参数),当前仅有 hotBrand(热门品牌,固定 20 条)。
|
||||
// 拼装逻辑由 service.IndexService.Runway 负责,handler 只做转发。
|
||||
func (h *SSGHandler) IndexRunway(c *gin.Context) {
|
||||
data, err := h.index.Runway(c.Request.Context(), parseLocale(c))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
list, err := h.brands.Popular(c.Request.Context(), limit, parseLocale(c))
|
||||
response.Data(c, http.StatusOK, data)
|
||||
}
|
||||
|
||||
// HotBrands 走秀页(RunwayLooks)品牌筛选的热门品牌数据。
|
||||
//
|
||||
// GET /api/v1/ssg/brands/hot
|
||||
//
|
||||
// 固定返回热度前 30 的品牌(按走秀图片总数排名,结果按热度顺序),不接收任何参数——
|
||||
// 前端侧栏 filter 展示前 10 个,品牌弹窗 HOT 标签展示全部 30 个。
|
||||
func (h *SSGHandler) HotBrands(c *gin.Context) {
|
||||
const hotBrandLimit = 30
|
||||
|
||||
list, err := h.brands.Hot(c.Request.Context(), hotBrandLimit, parseLocale(c))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
@ -47,44 +58,4 @@ func (h *SSGHandler) Popular(c *gin.Context) {
|
||||
response.Data(c, http.StatusOK, list)
|
||||
}
|
||||
|
||||
// Brands 品牌索引页(HBX 风格 A-Z)的 SSG 全量数据。
|
||||
//
|
||||
// GET /api/ssg/brands?size=300&only_with_articles=1&featured=1
|
||||
//
|
||||
// 直接复用 BrandQuery + service.BrandService.List,支持字母/搜索/精选/分页等全部参数,
|
||||
// 返回 {data:[...]} 与公开列表同构,前端 brands.astro 无需任何适配。
|
||||
func (h *SSGHandler) Brands(c *gin.Context) {
|
||||
q := dto.BrandQuery{
|
||||
Letter: strings.ToUpper(strings.TrimSpace(c.Query("letter"))),
|
||||
Keyword: strings.TrimSpace(c.Query("keyword")),
|
||||
Page: queryInt(c, "page", 1),
|
||||
Size: queryInt(c, "size", 200),
|
||||
OnlyWithArticles: queryBool(c, "only_with_articles", true),
|
||||
Featured: queryBool(c, "featured", false),
|
||||
Metric: strings.ToLower(strings.TrimSpace(c.Query("metric"))),
|
||||
Limit: queryInt(c, "limit", 200),
|
||||
Locale: parseLocale(c),
|
||||
}
|
||||
q.Normalize()
|
||||
|
||||
list, total, err := h.brands.List(c.Request.Context(), q)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
response.Page(c, http.StatusOK, list, total, q.Page, q.Size)
|
||||
}
|
||||
|
||||
// ArticleIDs 供 SSG 的 getStaticPaths 枚举详情页路径用。
|
||||
//
|
||||
// GET /api/ssg/articles/ids
|
||||
//
|
||||
// 只回 [id,...](升序),不带回封面/描述等大字段,比拉 size=500 再 .map(id) 省带宽。
|
||||
func (h *SSGHandler) ArticleIDs(c *gin.Context) {
|
||||
ids, err := h.articles.IDs(c.Request.Context())
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
response.Data(c, http.StatusOK, ids)
|
||||
}
|
||||
|
||||
@ -21,6 +21,10 @@ type BrandRepository interface {
|
||||
// 按 metric(images = 图片总数 / shows = 发布会数)排名取前 limit 个。
|
||||
FeaturedIDs(ctx context.Context, metric string, limit int) ([]uint32, error)
|
||||
|
||||
// ListByIDs 按 id 集合取品牌行(不保证顺序,调用方按热度自行重排)。
|
||||
// 供 Hot 等已知 id 列表的场景使用,避免借道 List 的 featured 旗标复用查询。
|
||||
ListByIDs(ctx context.Context, ids []uint32) ([]model.Brand, error)
|
||||
|
||||
// ArticleCountByBrandIDs 批量统计各品牌的走秀档案数,单次分组查询避免 N+1。
|
||||
ArticleCountByBrandIDs(ctx context.Context, ids []uint32) (map[uint32]int, error)
|
||||
|
||||
@ -81,7 +85,9 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3
|
||||
}
|
||||
|
||||
// 中文分桶不限档案,直接列出全部中文名品牌,便于检索
|
||||
if q.OnlyWithArticles && q.Letter != "OTHER" {
|
||||
// 关键词搜索时不限制"必须有走秀档案":用户按名字找品牌,
|
||||
// 即使该品牌暂无走秀档案也应被命中(如 A Bathing Ape / 猿人头)。
|
||||
if q.OnlyWithArticles && q.Keyword == "" && q.Letter != "OTHER" {
|
||||
db = db.Where("id IN (?)", r.hasArticlesSubQuery())
|
||||
}
|
||||
|
||||
@ -157,6 +163,20 @@ func (r *brandRepository) FeaturedIDs(ctx context.Context, metric string, limit
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// ListByIDs 按 id 集合取品牌行;MySQL IN 查询不保证返回顺序,调用方自行重排。
|
||||
func (r *brandRepository) ListByIDs(ctx context.Context, ids []uint32) ([]model.Brand, error) {
|
||||
if len(ids) == 0 {
|
||||
return []model.Brand{}, nil
|
||||
}
|
||||
var items []model.Brand
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("is_deleted = 0 AND id IN ?", ids).
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *brandRepository) ArticleCountByBrandIDs(ctx context.Context, ids []uint32) (map[uint32]int, error) {
|
||||
counts := make(map[uint32]int, len(ids))
|
||||
if len(ids) == 0 {
|
||||
|
||||
@ -36,7 +36,7 @@ func New(opt Options) *gin.Engine {
|
||||
// 前端用 toAbs() 拼上 base 后直接访问,因此这里必须对外提供静态文件。
|
||||
r.Static(opt.Config.Upload.URLPrefix, opt.Config.Upload.Dir)
|
||||
|
||||
api := r.Group("/api")
|
||||
api := r.Group("/api/v1")
|
||||
{
|
||||
// 健康检查,供容器 / 反向代理探活
|
||||
api.GET("/health", opt.Health.Check)
|
||||
@ -52,10 +52,9 @@ func New(opt Options) *gin.Engine {
|
||||
// 账号体系:注册 / 登录 / 登出 / 当前用户(JWT 无状态)
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/register", opt.Auth.Register)
|
||||
auth.POST("/login", opt.Auth.Login)
|
||||
auth.POST("/logout", opt.Auth.Logout)
|
||||
auth.GET("/me", middleware.Auth(opt.JWT), opt.Auth.Me)
|
||||
auth.POST("/register", opt.Auth.Register)
|
||||
auth.POST("/login", opt.Auth.Login)
|
||||
auth.GET("/me", middleware.Auth(opt.JWT), opt.Auth.Me)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -27,11 +27,10 @@ func NewSSG(opt Options) *gin.Engine {
|
||||
r.Use(ssgLogger())
|
||||
r.Use(middleware.SSGToken(opt.Config.Server.SSGToken))
|
||||
|
||||
ssg := r.Group("/api/ssg")
|
||||
ssg := r.Group("/api/v1/ssg")
|
||||
{
|
||||
ssg.GET("/brands", opt.SSG.Brands) // 品牌索引页全量数据(A-Z)
|
||||
ssg.GET("/brands/popular", opt.SSG.Popular) // 首页热门品牌区块
|
||||
ssg.GET("/articles/ids", opt.SSG.ArticleIDs) // 文章详情页路径枚举(getStaticPaths)
|
||||
ssg.GET("/brands/hot", opt.SSG.HotBrands) // 走秀页热门品牌(前 30,热度顺序)
|
||||
ssg.GET("/index/runway", opt.SSG.IndexRunway) // 首页(/index/)runway 区块:多模块,当前含 hotBrand
|
||||
}
|
||||
|
||||
return r
|
||||
|
||||
@ -51,7 +51,9 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu
|
||||
Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale),
|
||||
Summary: textutil.Summarize(pickLocale(r.DescriptionEn, r.DescriptionCn, q.Locale), summaryLength),
|
||||
Cover: r.Cover,
|
||||
BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, q.Locale),
|
||||
// 品牌名强制优先英文("en"),仅在英文缺失时回落中文,避免留空;
|
||||
// 与 title/description 不同,品牌名不随 locale 切换,保持英文观感。
|
||||
BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"),
|
||||
Year: r.Year,
|
||||
ImageCount: r.ImageCount,
|
||||
CollectionType: r.CollectionType,
|
||||
@ -111,7 +113,8 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string) (
|
||||
Summary: textutil.Summarize(pickLocale(row.DescriptionEn, row.DescriptionCn, locale), summaryLength),
|
||||
Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale),
|
||||
Cover: row.Cover,
|
||||
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, locale),
|
||||
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
||||
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
|
||||
Year: row.Year,
|
||||
ImageCount: row.ImageCount,
|
||||
SourceURL: row.SourceURL,
|
||||
|
||||
@ -2,8 +2,10 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/hashid"
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
@ -11,9 +13,10 @@ import (
|
||||
// BrandService 品牌业务接口。
|
||||
type BrandService interface {
|
||||
List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error)
|
||||
// Popular 首页"热门品牌"区块:按图片数排名的前 limit 个品牌,附带代表走秀封面与标题。
|
||||
// locale 决定品牌名/标题的展示语言(cn/en)。
|
||||
Popular(ctx context.Context, limit int, locale string) ([]dto.PopularBrand, error)
|
||||
// Hot 返回热门品牌(按走秀图片数排名前 limit 个)并附带各品牌档案数。
|
||||
// 供 SSG 构建期(RunwayLooks 侧栏 / 品牌弹窗 HOT 标签)一次性取前 N 个热门品牌;
|
||||
// 复用 List 的 featured 收窄逻辑(FeaturedIDs("images", limit)),与首页精选口径一致。
|
||||
Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error)
|
||||
}
|
||||
|
||||
type brandService struct {
|
||||
@ -58,34 +61,54 @@ func (s *brandService) List(ctx context.Context, q dto.BrandQuery) ([]dto.Public
|
||||
return nil, 0, internalErr(err.Error())
|
||||
}
|
||||
|
||||
list := s.toPublicBrands(items, counts, q.Locale)
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// Hot 返回热门品牌:按走秀图片总数排名取前 limit 个,结果按热度顺序(而非字母序)排列。
|
||||
//
|
||||
// 供 SSG 构建期(走秀页侧栏品牌 filter / 品牌弹窗 HOT 标签)一次性取用——
|
||||
// 前端不再从全量列表里自行切片,热度排名由后端唯一口径给出。
|
||||
func (s *brandService) Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error) {
|
||||
ids, err := s.brands.FeaturedIDs(ctx, "images", limit)
|
||||
if err != nil {
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return []dto.PublicBrand{}, nil
|
||||
}
|
||||
|
||||
items, err := s.brands.ListByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
counts, err := s.brands.ArticleCountByBrandIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
|
||||
// IN 查询不保证顺序,这里重排回 FeaturedIDs 的热度排名顺序。
|
||||
pos := make(map[uint32]int, len(ids))
|
||||
for i, id := range ids {
|
||||
pos[id] = i
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return pos[items[i].ID] < pos[items[j].ID] })
|
||||
|
||||
return s.toPublicBrands(items, counts, locale), nil
|
||||
}
|
||||
|
||||
// toPublicBrands 把仓储层品牌行映射为对外精简结构(编码 id + 双语名 + 档案数)。
|
||||
func (s *brandService) toPublicBrands(items []model.Brand, counts map[uint32]int, locale string) []dto.PublicBrand {
|
||||
list := make([]dto.PublicBrand, 0, len(items))
|
||||
for _, b := range items {
|
||||
list = append(list, dto.PublicBrand{
|
||||
UID: hashid.Encode(b.ID),
|
||||
Name: pickLocale(b.NameEn, b.NameCn, q.Locale),
|
||||
Name: pickLocale(b.NameEn, b.NameCn, locale),
|
||||
NameEn: b.NameEn,
|
||||
NameCn: b.NameCn,
|
||||
ShowName: b.NameCn,
|
||||
ArticleCount: counts[b.ID],
|
||||
})
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// Popular 热门品牌(首页区块用):按图片数排名的前 limit 个品牌,附带代表走秀封面与标题。
|
||||
// 复用仓储层的 FeaturedIDs 排名逻辑,保持与"代表品牌"一致的"热门"定义。locale 选展示语言。
|
||||
func (s *brandService) Popular(ctx context.Context, limit int, locale string) ([]dto.PopularBrand, error) {
|
||||
rows, err := s.brands.PopularWithCover(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
out := make([]dto.PopularBrand, 0, len(rows))
|
||||
for _, b := range rows {
|
||||
out = append(out, dto.PopularBrand{
|
||||
UID: hashid.Encode(b.ID),
|
||||
ArticleUID: hashid.Encode(b.ArticleID),
|
||||
Brand: pickLocale(b.BrandEn, b.BrandCn, locale),
|
||||
Cover: b.Cover,
|
||||
Title: pickLocale(b.TitleEn, b.TitleCn, locale),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
return list
|
||||
}
|
||||
|
||||
60
internal/service/index_service.go
Normal file
60
internal/service/index_service.go
Normal file
@ -0,0 +1,60 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/pkg/hashid"
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
|
||||
// IndexService 首页(/index/)各区块数据的聚合服务。
|
||||
//
|
||||
// 首页由多个独立区块(runway 等)组成,每个区块的数据各自组合、互不干扰。
|
||||
// 该服务专管「首页区块数据」这一领域,避免把首页数据拼装逻辑散落到 BrandService 等其它服务里。
|
||||
type IndexService interface {
|
||||
// Runway 首页 runway 区块数据:包含 hotBrand(热门品牌,固定 20 条)等多个模块。
|
||||
// 服务端写死各模块数量,不接收任何筛选参数;locale 决定标题展示语言。
|
||||
Runway(ctx context.Context, locale string) (IndexRunwayData, error)
|
||||
}
|
||||
|
||||
// IndexRunwayData 首页 runway 区块数据,按模块拆分;hotBrand 为其中之一。
|
||||
//
|
||||
// 后续新增首页 runway 模块时,在此结构体追加字段即可,前端按需读取,互不影响。
|
||||
type IndexRunwayData struct {
|
||||
HotBrand []dto.PopularBrand `json:"hotBrand"`
|
||||
}
|
||||
|
||||
type indexService struct {
|
||||
brands repository.BrandRepository
|
||||
}
|
||||
|
||||
// NewIndexService 创建首页数据服务。
|
||||
func NewIndexService(brands repository.BrandRepository) IndexService {
|
||||
return &indexService{brands: brands}
|
||||
}
|
||||
|
||||
// Runway 首页 runway 区块数据。
|
||||
//
|
||||
// 复用仓储层 PopularWithCover 的「按图片数排名取热门品牌 + 代表走秀封面/标题」逻辑,
|
||||
// 保持与品牌「代表品牌」一致的"热门"定义;品牌名强制英文观感,标题按 locale 选列。
|
||||
// hotBrand 数量写死 20,接口不接收任何参数。
|
||||
func (s *indexService) Runway(ctx context.Context, locale string) (IndexRunwayData, error) {
|
||||
const hotBrandLimit = 20
|
||||
rows, err := s.brands.PopularWithCover(ctx, hotBrandLimit)
|
||||
if err != nil {
|
||||
return IndexRunwayData{}, internalErr(err.Error())
|
||||
}
|
||||
brands := make([]dto.PopularBrand, 0, len(rows))
|
||||
for _, b := range rows {
|
||||
brands = append(brands, dto.PopularBrand{
|
||||
UID: hashid.Encode(b.ID),
|
||||
ArticleUID: hashid.Encode(b.ArticleID),
|
||||
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
||||
Brand: pickLocale(b.BrandEn, b.BrandCn, "en"),
|
||||
Cover: b.Cover,
|
||||
Title: pickLocale(b.TitleEn, b.TitleCn, locale),
|
||||
})
|
||||
}
|
||||
return IndexRunwayData{HotBrand: brands}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user