diff --git a/_server.exe b/_server.exe deleted file mode 100644 index 0e6a046..0000000 Binary files a/_server.exe and /dev/null differ diff --git a/internal/dto/article.go b/internal/dto/article.go index 62e6491..85a3a31 100644 --- a/internal/dto/article.go +++ b/internal/dto/article.go @@ -32,6 +32,10 @@ const ( articleMaxSize = 100 ArticleFixedImages = 6 // 列表每篇附带前 N 张缩略图 imagesMax = 12 + + // PreviewLimit 未登录用户在列表接口可见的预览条数上限; + // 超过部分由前端登录门禁提示,避免公开接口一次性吐出全部图集。 + PreviewLimit = 5 ) // Normalize 校正分页与附图参数,防止非法值打穿数据库。 diff --git a/internal/handler/article_handler.go b/internal/handler/article_handler.go index fa3f332..cea03b7 100644 --- a/internal/handler/article_handler.go +++ b/internal/handler/article_handler.go @@ -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) } diff --git a/internal/handler/street_snap_handler.go b/internal/handler/street_snap_handler.go index d0b249f..a82884f 100644 --- a/internal/handler/street_snap_handler.go +++ b/internal/handler/street_snap_handler.go @@ -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) } diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 160d1a9..4000466 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -51,15 +51,29 @@ func requireAuth(c *gin.Context, manager *jwt.Manager) { // PublicFirstPageAuth 列表接口「首页公开、翻页需登录」鉴权中间件。 // -// 仅当分页参数 page 缺失或 <= 1 时放行(无需 token),其余页要求有效的 -// Authorization: Bearer ,否则返回 401。用于让列表首屏对游客/SEO 可见, -// 同时保留翻页 / 批量枚举的登录门槛(防爬虫直接 dump 全量)。 +// 行为: +// - 始终尝试解析 Authorization: Bearer (若存在且有效则写入 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)放行 diff --git a/internal/pkg/response/response.go b/internal/pkg/response/response.go index 00aafdd..51d5c32 100644 --- a/internal/pkg/response/response.go +++ b/internal/pkg/response/response.go @@ -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}) diff --git a/internal/router/router.go b/internal/router/router.go index e9b63f1..4494135 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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 落库可吊销)。 // diff --git a/server-bin.exe b/server-bin.exe deleted file mode 100644 index d0d9228..0000000 Binary files a/server-bin.exe and /dev/null differ