This commit is contained in:
toom1996
2026-09-03 19:45:20 +08:00
parent ffe84efc4c
commit b049104a0b
8 changed files with 99 additions and 22 deletions

Binary file not shown.

View File

@ -32,6 +32,10 @@ const (
articleMaxSize = 100
ArticleFixedImages = 6 // 列表每篇附带前 N 张缩略图
imagesMax = 12
// PreviewLimit 未登录用户在列表接口可见的预览条数上限;
// 超过部分由前端登录门禁提示,避免公开接口一次性吐出全部图集。
PreviewLimit = 5
)
// Normalize 校正分页与附图参数,防止非法值打穿数据库。

View File

@ -6,6 +6,7 @@ import (
"strings"
"fashionapi/internal/dto"
"fashionapi/internal/middleware"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/response"
"fashionapi/internal/service"
@ -38,7 +39,22 @@ func NewArticleHandler(articles service.ArticleService) *ArticleHandler {
//
// 每篇附带前 ArticleFixedImages=6 张缩略图(服务端固定,不进参数)。
func (h *ArticleHandler) List(c *gin.Context) {
h.respondRunways(c, h.parseListQuery(c))
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 解析走秀列表的筛选条件。
@ -62,17 +78,6 @@ func (h *ArticleHandler) parseListQuery(c *gin.Context) dto.ArticleQuery {
return q
}
// respondRunways 统一执行列表查询并回包(分页)。
func (h *ArticleHandler) respondRunways(c *gin.Context, q dto.ArticleQuery) {
q.Normalize()
list, total, err := h.articles.List(c.Request.Context(), q)
if err != nil {
fail(c, err)
return
}
response.Page(c, http.StatusOK, list, total, q.Size)
}
// Detail 走秀详情(含完整图片集)。
//
// GET /api/public/runways/:id
@ -94,5 +99,15 @@ func (h *ArticleHandler) Detail(c *gin.Context) {
fail(c, err)
return
}
response.Data(c, http.StatusOK, detail)
// 未登录:图片集仅返回预览上限(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
}
response.DataPreview(c, http.StatusOK, detail, preview, imageTotal)
}

View File

@ -44,6 +44,8 @@ func (h *StreetSnapHandler) List(c *gin.Context) {
fail(c, err)
return
}
// 列表首屏对游客完全公开(翻页门槛由 PublicFirstPageAuth 兜底),不做预览截断——
// 「未登录限制」已转移到详情页:每篇图集最多看 PreviewLimit 张图片,列表照常展示全部专辑。
response.Page(c, http.StatusOK, list, total, q.Size)
}
@ -81,5 +83,15 @@ func (h *StreetSnapHandler) Detail(c *gin.Context) {
fail(c, err)
return
}
response.Data(c, http.StatusOK, detail)
// 未登录:图片集仅返回预览上限(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
}
response.DataPreview(c, http.StatusOK, detail, preview, imageTotal)
}

View File

@ -51,15 +51,29 @@ func requireAuth(c *gin.Context, manager *jwt.Manager) {
// PublicFirstPageAuth 列表接口「首页公开、翻页需登录」鉴权中间件。
//
// 仅当分页参数 page 缺失或 <= 1 时放行(无需 token),其余页要求有效的
// Authorization: Bearer <token>,否则返回 401。用于让列表首屏对游客/SEO 可见,
// 同时保留翻页 / 批量枚举的登录门槛(防爬虫直接 dump 全量)。
// 行为:
// - 始终尝试解析 Authorization: Bearer <token>(若存在且有效则写入 Context),
// 供下游 handler 判断登录态(如未登录预览截断)。
// - 仅当 page>1 时才强制要求有效 token,否则返回 401;首页(page 缺失或 <= 1)
// 无 token 也能放行,让游客/SEO 看到首屏。
//
// 这样既让列表首屏对游客可见,又保留翻页 / 批量枚举的登录门槛(防爬虫 dump 全量)。
func PublicFirstPageAuth(manager *jwt.Manager) gin.HandlerFunc {
return func(c *gin.Context) {
// 解析并写入登录态(若存在有效 token),但不强制。
if header := c.GetHeader("Authorization"); strings.HasPrefix(header, bearerPrefix) {
if claims, err := manager.Parse(strings.TrimPrefix(header, bearerPrefix)); err == nil {
c.Set(ContextUserID, claims.UserID)
c.Set(ContextUsername, claims.Username)
}
}
// 非首页:必须有有效 token,否则 401。
if pageStr := c.Query("page"); pageStr != "" {
if n, err := strconv.Atoi(pageStr); err == nil && n > 1 {
requireAuth(c, manager) // 非首页:走完整 JWT 校验
return
if _, ok := c.Get(ContextUserID); !ok {
response.AbortError(c, http.StatusUnauthorized, "missing or invalid token")
return
}
}
}
c.Next() // 首页(page 缺失或 <= 1)放行

View File

@ -15,6 +15,21 @@ func Data(c *gin.Context, status int, v any) {
c.JSON(status, gin.H{"data": v})
}
// DataPreview 同 Data,但可附加 preview 标记(详情接口对未登录请求截断图片集时 preview=true)。
//
// 详情接口对未登录请求只返回预览上限(PreviewLimit)张图片,并打 preview=true,
// 前端据此在详情页展示「登录查看全部图片」门禁;已登录(携带有效 Bearer)返回完整图片集,不打标记。
// imageTotal 为图片集的真实总数(截断前),前端用它渲染「剩余 N 张需登录」的占位遮罩,
// 避免匿名用户误以为图集只有预览上限那几张。
func DataPreview(c *gin.Context, status int, v any, preview bool, imageTotal int) {
m := gin.H{"data": v}
if preview {
m["preview"] = true
}
m["image_total"] = imageTotal
c.JSON(status, m)
}
// Page 返回分页列表:{ data, last_page }。
//
// 只保留前端真正消费的字段:data(本页数据)与 last_page(总页数,供页码导航)。
@ -26,6 +41,21 @@ func Page(c *gin.Context, status int, data any, total int64, size int) {
})
}
// PagePreview 同 Page,但可附加 preview 标记(未登录截断预览时 preview=true)。
//
// 列表接口对未登录请求只返回预览上限(PreviewLimit)条数据,并打 preview=true,
// 前端据此弹出「登录查看全部」门禁;已登录或总量本就不超上限时不打标记。
func PagePreview(c *gin.Context, status int, data any, total int64, size int, preview bool) {
m := gin.H{
"data": data,
"last_page": LastPage(total, size),
}
if preview {
m["preview"] = true
}
c.JSON(status, m)
}
// Error 返回错误:{ "error": msg }。
func Error(c *gin.Context, status int, msg string) {
c.JSON(status, gin.H{"error": msg})

View File

@ -65,9 +65,11 @@ func New(opt Options) *gin.Engine {
}
// 走秀详情:被 SSR 服务端按需渲染(getSsrArticle)直接 fetch 调用,属可信内部取数,
// 不走前端 JS 签名,单独挂载以免破坏服务端渲染。单篇枚举防护由 HashID 承担。
api.GET("/public/runway-looks/:id", opt.Article.Detail)
// 街拍详情:同走秀详情,单条枚举防护由 HashID 承担,不走前端签名。
api.GET("/public/street-snaps/:id", opt.StreetSnap.Detail)
// 挂载 PublicFirstPageAuth 仅用于「解析 Bearer 写入登录态」:游客(无 token)照常放行,
// 已登录用户则拿到完整图片集(未登录截断到 PreviewLimit 张)。
api.GET("/public/runway-looks/:id", middleware.PublicFirstPageAuth(opt.JWT), opt.Article.Detail)
// 街拍详情:同走秀详情,单条枚举防护由 HashID 承担,不走前端签名;同样挂载 PublicFirstPageAuth 解析登录态。
api.GET("/public/street-snaps/:id", middleware.PublicFirstPageAuth(opt.JWT), opt.StreetSnap.Detail)
// 账号体系:双令牌(access 短命无状态 + refresh 落库可吊销)。
//

Binary file not shown.