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/public/runways/:id // :id 是对外编码串(无序),需先解码为数字主键再查库;非法串视为不存在。 func (h *ArticleHandler) Detail(c *gin.Context) { raw := strings.TrimSpace(c.Param("id")) if raw == "" { response.Error(c, http.StatusBadRequest, "缺少文章 id") return } _, numeric, err := hashid.DecodeTyped(raw) 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 { fail(c, err) return } // 未登录:图片集仅返回预览上限(PreviewLimit)张,并打 preview=true, // 前端据此在详情页展示「登录查看全部图片」门禁;已登录(带有效 Bearer)返回完整图片集。 anon := isAnon(c) preview := false imageTotal := len(detail.Images) if anon && imageTotal > dto.PreviewLimit { detail.Images = detail.Images[:dto.PreviewLimit] preview = true } // 已登录:把当前用户对该图集(gallery 级)与每张图片(image 级)的收藏态随详情返回, // 前端无需再单独调 /me/favorites/checks。gallery uid(r=)与图片 id(i=)前缀不同, // 混传 FilterExisting 不影响查询;匿名请求不查(保持公开接口无用户态、可缓存)。 if uid, ok := middleware.UserIDFrom(c); ok { ids := make([]string, 0, len(detail.Images)+1) ids = append(ids, detail.UID) for _, im := range detail.Images { if im.ID != "" { ids = append(ids, im.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] } } } response.DataPreview(c, http.StatusOK, detail, preview, imageTotal) }