Files
backend_v2/internal/handler/street_snap_handler.go
toom1996 42f6316125 update
2026-09-25 11:31:52 +08:00

134 lines
4.8 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"
)
// StreetSnapHandler 对外公开的街拍接口。
type StreetSnapHandler struct {
svc service.StreetSnapService
favs service.FavoriteService
}
// NewStreetSnapHandler 创建街拍 handler。
func NewStreetSnapHandler(svc service.StreetSnapService, favs service.FavoriteService) *StreetSnapHandler {
return &StreetSnapHandler{svc: svc, favs: favs}
}
// List 街拍列表(分页 + 排序 + 年份过滤)。
//
// GET /api/v1/public/street-snaps
//
// 查询参数:
// - page 页码,默认 1(服务端固定每页 StreetSnapFixedSize=24 条)
// - year 年份(整数),0 表示不过滤
// - city 地区/城市(精确匹配 city 列),"" 表示不过滤
// - sort image_count(默认,按图片数降序)/ year_desc / year_asc
// - locale cn / en
//
// 每条附带前 StreetSnapFixedImages=6 张缩略图(服务端固定,不进参数)。
// 列表接口挂在 public 组,受 ClientSign 中间件保护(与 runway-looks / brands 同批批量接口)。
func (h *StreetSnapHandler) List(c *gin.Context) {
q := h.parseListQuery(c)
q.Normalize()
list, total, err := h.svc.List(c.Request.Context(), q)
if err != nil {
fail(c, err)
return
}
// 列表首屏对游客完全公开(翻页门槛由 PublicFirstPageAuth 兜底),不做预览截断——
// 「未登录限制」已转移到详情页:每篇图集最多看 PreviewLimit 张图片,列表照常展示全部专辑。
response.Page(c, http.StatusOK, list, total, q.Size)
}
func (h *StreetSnapHandler) parseListQuery(c *gin.Context) dto.StreetSnapQuery {
return dto.StreetSnapQuery{
Page: queryInt(c, "page", 1),
Size: dto.StreetSnapFixedSize, // 服务端固定,前端不传
Year: queryInt(c, "year", 0),
City: c.DefaultQuery("city", ""),
Sort: c.DefaultQuery("sort", "image_count"),
Images: dto.StreetSnapFixedImages, // 服务端固定,前端不传
Locale: parseLocale(c),
}
}
// Detail 街拍详情(含完整图片集)。
//
// GET /api/v1/public/street-snaps/:id
// :id 是对外编码串(无序),需先解码为数字主键再查库;非法串视为不存在。
// 不走前端 JS 签名,单独挂载(与 runway 详情一致),单篇枚举防护由 HashID 承担。
func (h *StreetSnapHandler) Detail(c *gin.Context) {
raw := strings.TrimSpace(c.Param("id"))
if raw == "" {
response.Error(c, http.StatusBadRequest, "缺少街拍 id")
return
}
// 类型由路由提供(/street-snaps 即 snap)。类型已揉进密码:非 snap 编码的串
// 在此解出的是错误主键,查库自然 404,从而强制前端按类型化路由分流(无需靠前缀嗅探)。
numeric, err := hashid.DecodeWithType(raw, hashid.TypeSnap)
if err != nil {
response.Error(c, http.StatusNotFound, "街拍不存在")
return
}
detail, err := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c))
if err != nil {
// 向后兼容:迁移前收藏/历史/旧外链可能仍带 s= 前缀(旧 EncodeTyped)。
// 主线按类型化密码解码不到实体时,按旧编码再解一次回退(仅当旧类型确为 snap)。
if ltyp, lnum, lerr := hashid.DecodeTyped(raw); lerr == nil && ltyp == hashid.TypeSnap {
if d2, e2 := h.svc.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,
// 前端据此在详情页展示「登录查看全部图片」门禁;已登录(带有效 Bearer)返回完整图片集。
anon := isAnon(c)
preview := false
imageTotal := len(detail.Images)
if anon && imageTotal > dto.PreviewLimit {
detail.Images = detail.Images[:dto.PreviewLimit]
preview = true
}
// 已登录:图集(gallery 级 TypeSnap)与每张图片(image 级 TypeSnapImage)收藏态随详情返回,
// 前端无需再单独调 /me/favorites/checks。
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)
}