update
This commit is contained in:
BIN
_server.exe
BIN
_server.exe
Binary file not shown.
@ -32,6 +32,10 @@ const (
|
|||||||
articleMaxSize = 100
|
articleMaxSize = 100
|
||||||
ArticleFixedImages = 6 // 列表每篇附带前 N 张缩略图
|
ArticleFixedImages = 6 // 列表每篇附带前 N 张缩略图
|
||||||
imagesMax = 12
|
imagesMax = 12
|
||||||
|
|
||||||
|
// PreviewLimit 未登录用户在列表接口可见的预览条数上限;
|
||||||
|
// 超过部分由前端登录门禁提示,避免公开接口一次性吐出全部图集。
|
||||||
|
PreviewLimit = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
// Normalize 校正分页与附图参数,防止非法值打穿数据库。
|
// Normalize 校正分页与附图参数,防止非法值打穿数据库。
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
|
"fashionapi/internal/middleware"
|
||||||
"fashionapi/internal/pkg/hashid"
|
"fashionapi/internal/pkg/hashid"
|
||||||
"fashionapi/internal/pkg/response"
|
"fashionapi/internal/pkg/response"
|
||||||
"fashionapi/internal/service"
|
"fashionapi/internal/service"
|
||||||
@ -38,7 +39,22 @@ func NewArticleHandler(articles service.ArticleService) *ArticleHandler {
|
|||||||
//
|
//
|
||||||
// 每篇附带前 ArticleFixedImages=6 张缩略图(服务端固定,不进参数)。
|
// 每篇附带前 ArticleFixedImages=6 张缩略图(服务端固定,不进参数)。
|
||||||
func (h *ArticleHandler) List(c *gin.Context) {
|
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 解析走秀列表的筛选条件。
|
// parseListQuery 从 query string 解析走秀列表的筛选条件。
|
||||||
@ -62,17 +78,6 @@ func (h *ArticleHandler) parseListQuery(c *gin.Context) dto.ArticleQuery {
|
|||||||
return q
|
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 走秀详情(含完整图片集)。
|
// Detail 走秀详情(含完整图片集)。
|
||||||
//
|
//
|
||||||
// GET /api/public/runways/:id
|
// GET /api/public/runways/:id
|
||||||
@ -94,5 +99,15 @@ func (h *ArticleHandler) Detail(c *gin.Context) {
|
|||||||
fail(c, err)
|
fail(c, err)
|
||||||
return
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,6 +44,8 @@ func (h *StreetSnapHandler) List(c *gin.Context) {
|
|||||||
fail(c, err)
|
fail(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 列表首屏对游客完全公开(翻页门槛由 PublicFirstPageAuth 兜底),不做预览截断——
|
||||||
|
// 「未登录限制」已转移到详情页:每篇图集最多看 PreviewLimit 张图片,列表照常展示全部专辑。
|
||||||
response.Page(c, http.StatusOK, list, total, q.Size)
|
response.Page(c, http.StatusOK, list, total, q.Size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,5 +83,15 @@ func (h *StreetSnapHandler) Detail(c *gin.Context) {
|
|||||||
fail(c, err)
|
fail(c, err)
|
||||||
return
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,15 +51,29 @@ func requireAuth(c *gin.Context, manager *jwt.Manager) {
|
|||||||
|
|
||||||
// PublicFirstPageAuth 列表接口「首页公开、翻页需登录」鉴权中间件。
|
// PublicFirstPageAuth 列表接口「首页公开、翻页需登录」鉴权中间件。
|
||||||
//
|
//
|
||||||
// 仅当分页参数 page 缺失或 <= 1 时放行(无需 token),其余页要求有效的
|
// 行为:
|
||||||
// Authorization: Bearer <token>,否则返回 401。用于让列表首屏对游客/SEO 可见,
|
// - 始终尝试解析 Authorization: Bearer <token>(若存在且有效则写入 Context),
|
||||||
// 同时保留翻页 / 批量枚举的登录门槛(防爬虫直接 dump 全量)。
|
// 供下游 handler 判断登录态(如未登录预览截断)。
|
||||||
|
// - 仅当 page>1 时才强制要求有效 token,否则返回 401;首页(page 缺失或 <= 1)
|
||||||
|
// 无 token 也能放行,让游客/SEO 看到首屏。
|
||||||
|
//
|
||||||
|
// 这样既让列表首屏对游客可见,又保留翻页 / 批量枚举的登录门槛(防爬虫 dump 全量)。
|
||||||
func PublicFirstPageAuth(manager *jwt.Manager) gin.HandlerFunc {
|
func PublicFirstPageAuth(manager *jwt.Manager) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
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 pageStr := c.Query("page"); pageStr != "" {
|
||||||
if n, err := strconv.Atoi(pageStr); err == nil && n > 1 {
|
if n, err := strconv.Atoi(pageStr); err == nil && n > 1 {
|
||||||
requireAuth(c, manager) // 非首页:走完整 JWT 校验
|
if _, ok := c.Get(ContextUserID); !ok {
|
||||||
return
|
response.AbortError(c, http.StatusUnauthorized, "missing or invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.Next() // 首页(page 缺失或 <= 1)放行
|
c.Next() // 首页(page 缺失或 <= 1)放行
|
||||||
|
|||||||
@ -15,6 +15,21 @@ func Data(c *gin.Context, status int, v any) {
|
|||||||
c.JSON(status, gin.H{"data": v})
|
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 }。
|
// Page 返回分页列表:{ data, last_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 }。
|
// Error 返回错误:{ "error": msg }。
|
||||||
func Error(c *gin.Context, status int, msg string) {
|
func Error(c *gin.Context, status int, msg string) {
|
||||||
c.JSON(status, gin.H{"error": msg})
|
c.JSON(status, gin.H{"error": msg})
|
||||||
|
|||||||
@ -65,9 +65,11 @@ func New(opt Options) *gin.Engine {
|
|||||||
}
|
}
|
||||||
// 走秀详情:被 SSR 服务端按需渲染(getSsrArticle)直接 fetch 调用,属可信内部取数,
|
// 走秀详情:被 SSR 服务端按需渲染(getSsrArticle)直接 fetch 调用,属可信内部取数,
|
||||||
// 不走前端 JS 签名,单独挂载以免破坏服务端渲染。单篇枚举防护由 HashID 承担。
|
// 不走前端 JS 签名,单独挂载以免破坏服务端渲染。单篇枚举防护由 HashID 承担。
|
||||||
api.GET("/public/runway-looks/:id", opt.Article.Detail)
|
// 挂载 PublicFirstPageAuth 仅用于「解析 Bearer 写入登录态」:游客(无 token)照常放行,
|
||||||
// 街拍详情:同走秀详情,单条枚举防护由 HashID 承担,不走前端签名。
|
// 已登录用户则拿到完整图片集(未登录截断到 PreviewLimit 张)。
|
||||||
api.GET("/public/street-snaps/:id", opt.StreetSnap.Detail)
|
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 落库可吊销)。
|
// 账号体系:双令牌(access 短命无状态 + refresh 落库可吊销)。
|
||||||
//
|
//
|
||||||
|
|||||||
BIN
server-bin.exe
BIN
server-bin.exe
Binary file not shown.
Reference in New Issue
Block a user