Files
backend_v2/internal/handler/article_handler.go
2026-09-20 01:06:13 +08:00

159 lines
5.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"net/http"
"strconv"
"strings"
"fashionapi/internal/dto"
"fashionapi/internal/middleware"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/response"
"fashionapi/internal/service"
"github.com/gin-gonic/gin"
)
// ArticleHandler 对外公开的文章接口。
type ArticleHandler struct {
articles service.ArticleService
favs service.FavoriteService
}
// NewArticleHandler 创建文章 handler。
func NewArticleHandler(articles service.ArticleService, favs service.FavoriteService) *ArticleHandler {
return &ArticleHandler{articles: articles, favs: favs}
}
// List 走秀列表(分页 + 单值筛选 + 排序)。
//
// GET /api/v1/public/runway-looks
//
// 查询参数(均为单值,size / images 由服务端固定,前端不传):
// - page 页码,默认 1(服务端固定每页 ArticleFixedSize=24 条)
// - collection rtw / menswear / couture / resort / pre_fall
// - season spring / fall
// - year 年份(整数),0 表示不过滤
// - sort newest(默认)/ year_desc / year_asc / image_count
// - brand_id hashid 编码串;传入则只返回该品牌走秀(查询参数,取代原嵌套路由 /brands/:brandId/runway-looks)
// - locale cn / en
//
// 每篇附带前 ArticleFixedImages=6 张缩略图(服务端固定,不进参数)。
func (h *ArticleHandler) List(c *gin.Context) {
q := h.parseListQuery(c)
q.Normalize()
list, total, err := h.articles.List(c.Request.Context(), q)
if err != nil {
fail(c, err)
return
}
// 列表首屏对游客完全公开(翻页门槛由 PublicFirstPageAuth 兜底),不做预览截断——
// 「未登录限制」已转移到详情页:每篇图集最多看 PreviewLimit 张图片,列表照常展示全部专辑。
response.Page(c, http.StatusOK, list, total, q.Size)
}
// isAnon 判断请求是否未携带有效登录态(供列表接口做未登录预览截断)。
func isAnon(c *gin.Context) bool {
_, ok := middleware.UserIDFrom(c)
return !ok
}
// parseListQuery 从 query string 解析走秀列表的筛选条件。
// brand_id 为 hashid 编码串;非法串忽略(当作不过滤,返回空结果集而非 404)。
func (h *ArticleHandler) parseListQuery(c *gin.Context) dto.ArticleQuery {
q := dto.ArticleQuery{
Page: queryInt(c, "page", 1),
Size: dto.ArticleFixedSize, // 服务端固定,前端不传
Collection: c.Query("collection"),
Season: c.Query("season"),
Year: queryInt(c, "year", 0),
Sort: c.DefaultQuery("sort", "newest"),
Images: dto.ArticleFixedImages, // 服务端固定,前端不传
Locale: parseLocale(c),
}
if raw := strings.TrimSpace(c.Query("brand_id")); raw != "" {
if n, err := hashid.Decode(raw); err == nil {
q.BrandIDs = []uint32{n}
}
}
return q
}
// Detail 走秀详情(含完整图片集)。
//
// GET /api/v1/public/runway-looks/:id
// :id 是对外编码串(无序),需先解码为数字主键再查库;非法串视为不存在。
func (h *ArticleHandler) Detail(c *gin.Context) {
raw := strings.TrimSpace(c.Param("id"))
if raw == "" {
response.Error(c, http.StatusBadRequest, "缺少文章 id")
return
}
// 类型由路由提供(/runway-looks 即 runway)。类型已揉进密码:非 runway 编码的串
// 在此解出的是错误主键,查库自然 404,从而强制前端按类型化路由分流(无需靠前缀嗅探)。
numeric, err := hashid.DecodeWithType(raw, hashid.TypeRunway)
if err != nil {
response.Error(c, http.StatusNotFound, "文章不存在")
return
}
detail, err := h.articles.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c))
if err != nil {
// 向后兼容:迁移前收藏/历史/旧外链可能仍带 r= 前缀(旧 EncodeTyped)。
// 主线按类型化密码解码不到实体时,按旧编码再解一次回退(仅当旧类型确为 runway)。
if ltyp, lnum, lerr := hashid.DecodeTyped(raw); lerr == nil && ltyp == hashid.TypeRunway {
if d2, e2 := h.articles.Detail(c.Request.Context(), strconv.FormatUint(uint64(lnum), 10), parseLocale(c)); e2 == nil {
detail, err = d2, nil
}
}
if err != nil {
fail(c, err)
return
}
}
// 未登录:主图只返回预览上限(PreviewLimit)张并打 preview=true,前端据此展示
// 「登录查看全部图片」门禁。细节图(Detail 子数组)不额外剥离 —— 游客也能看前几张主图的细节,
// 体验更完整;而第 PreviewLimit 张之后的主图连同其细节图会被整组截断丢弃,故无法借此绕过门禁。
anon := isAnon(c)
preview := false
imageTotal := len(detail.Images)
if anon && imageTotal > dto.PreviewLimit {
detail.Images = detail.Images[:dto.PreviewLimit]
preview = true
}
// 已登录:把当前用户对该图集(gallery 级 TypeRunway)与每张图片(image 级 TypeRunwayImage,含细节图)的收藏态
// 随详情返回,前端无需再单独调 /me/favorites/checks。gallery uid 与图片 id 因类型进密码、串值不同,
// 混传 FilterExisting 不影响查询;匿名请求不查(保持公开接口无用户态、可缓存)。
if uid, ok := middleware.UserIDFrom(c); ok {
ids := make([]string, 0, len(detail.Images)*2+1)
ids = append(ids, detail.UID)
for _, im := range detail.Images {
if im.ID != "" {
ids = append(ids, im.ID)
}
for _, kid := range im.Detail {
if kid.ID != "" {
ids = append(ids, kid.ID)
}
}
}
if hit, err := h.favs.Check(c.Request.Context(), uid, ids); err == nil {
set := make(map[string]bool, len(hit))
for _, id := range hit {
set[id] = true
}
detail.Favorited = set[detail.UID]
for i := range detail.Images {
detail.Images[i].Favorited = set[detail.Images[i].ID]
for j := range detail.Images[i].Detail {
detail.Images[i].Detail[j].Favorited = set[detail.Images[i].Detail[j].ID]
}
}
}
}
response.DataPreview(c, http.StatusOK, detail, preview, imageTotal)
}