diff --git a/internal/dto/street_snap.go b/internal/dto/street_snap.go new file mode 100644 index 0000000..da547e0 --- /dev/null +++ b/internal/dto/street_snap.go @@ -0,0 +1,68 @@ +package dto + +// ---------- 街拍列表查询 ---------- + +// StreetSnapQuery 街拍列表查询条件(GET /public/street-snaps)。 +// +// 街拍不关联品牌,因此没有 brand_id / collection / season 等走秀特有的筛选维度; +// 仅保留通用的年份过滤与排序(按图片数或年份)。分页 size 与附图 images 由服务端固定。 +type StreetSnapQuery struct { + Page int + Size int // 服务端固定为 StreetSnapFixedSize,不从 query 读取 + Year int // 单值年份,0 表示不过滤 + Sort string // image_count(默认)/ year_desc / year_asc + Images int // 服务端固定为 StreetSnapFixedImages,不从 query 读取 + Locale string +} + +// 列表分页与附图数量由服务端固定(前端无调整入口),不从 query 读取。 +const ( + StreetSnapFixedSize = 24 + streetSnapMaxSize = 100 + StreetSnapFixedImages = 6 + streetSnapImagesMax = 12 +) + +// Normalize 校正分页与附图参数,防止非法值打穿数据库。 +func (q *StreetSnapQuery) Normalize() { + if q.Page < 1 { + q.Page = 1 + } + if q.Size < 1 || q.Size > streetSnapMaxSize { + q.Size = StreetSnapFixedSize + } + if q.Images < 0 { + q.Images = 0 + } + if q.Images > streetSnapImagesMax { + q.Images = streetSnapImagesMax + } + if q.Sort == "" { + q.Sort = "image_count" + } +} + +// Offset 返回 SQL 偏移量。 +func (q StreetSnapQuery) Offset() int { return (q.Page - 1) * q.Size } + +// ---------- 响应 ---------- + +// PublicStreetSnap 对外展示用的精简街拍结构(列表)。 +// +// 只返回前端真正渲染的字段:id(编码)/ title / cover / image_count / images(前 N 张缩略图)。 +// 街拍不关联品牌,故无 brand_id / brand_name。 +type PublicStreetSnap struct { + UID string `json:"id"` // 编码后的街拍 id(无序串) + Title string `json:"title"` + Cover string `json:"cover"` + ImageCount uint16 `json:"image_count"` // 该街拍图片总数(卡片 "N+" 角标用) + Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图 +} + +// PublicStreetSnapDetail 对外只读街拍详情:含完整图片集。 +type PublicStreetSnapDetail struct { + UID string `json:"id"` + Title string `json:"title"` + Cover string `json:"cover"` + Images []PublicArticleImage `json:"images"` +} diff --git a/internal/handler/street_snap_handler.go b/internal/handler/street_snap_handler.go new file mode 100644 index 0000000..e983f6d --- /dev/null +++ b/internal/handler/street_snap_handler.go @@ -0,0 +1,83 @@ +package handler + +import ( + "net/http" + "strconv" + "strings" + + "fashionapi/internal/dto" + "fashionapi/internal/pkg/hashid" + "fashionapi/internal/pkg/response" + "fashionapi/internal/service" + + "github.com/gin-gonic/gin" +) + +// StreetSnapHandler 对外公开的街拍接口。 +type StreetSnapHandler struct { + svc service.StreetSnapService +} + +// NewStreetSnapHandler 创建街拍 handler。 +func NewStreetSnapHandler(svc service.StreetSnapService) *StreetSnapHandler { + return &StreetSnapHandler{svc: svc} +} + +// List 街拍列表(分页 + 排序 + 年份过滤)。 +// +// GET /api/public/street-snaps +// +// 查询参数: +// - page 页码,默认 1(服务端固定每页 StreetSnapFixedSize=24 条) +// - year 年份(整数),0 表示不过滤 +// - 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 + } + 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), + Sort: c.DefaultQuery("sort", "image_count"), + Images: dto.StreetSnapFixedImages, // 服务端固定,前端不传 + Locale: parseLocale(c), + } +} + +// Detail 街拍详情(含完整图片集)。 +// +// GET /api/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 + } + numeric, err := hashid.Decode(raw) + if err != nil { + response.Error(c, http.StatusNotFound, "街拍不存在") + return + } + + detail, err := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10)) + if err != nil { + fail(c, err) + return + } + response.Data(c, http.StatusOK, detail) +} diff --git a/internal/middleware/clientsign.go b/internal/middleware/clientsign.go new file mode 100644 index 0000000..3b1c942 --- /dev/null +++ b/internal/middleware/clientsign.go @@ -0,0 +1,76 @@ +package middleware + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "strconv" + "strings" + "time" + + "fashionapi/internal/config" + "fashionapi/internal/pkg/response" + + "github.com/gin-gonic/gin" +) + +const ( + hdrSign = "X-Sign" + hdrTs = "X-Sign-Ts" + hdrNonce = "X-Sign-Nonce" +) + +// ClientSign 校验「公开接口」请求是否由前端 JS 签名(防简单爬虫/批量抓取的一层)。 +// +// 校验内容:HMAC-SHA256(secret, METHOD + "\n" + Path + "\n" + RawQuery + "\n" + ts + "\n" + nonce) +// 并要求 ts 在 ±TTL 秒以内(防重放)。 +// +// 安全说明(务必知悉): +// - 这是「提高成本」而非「加密」:secret 必须出现在前端 bundle 才能签名,因此本质上对浏览器可见, +// 有决心的爬虫可反编译 JS 复刻签名逻辑。它用于拖慢 casual 爬虫,不能作为唯一防线。 +// - 真正有效的组合是:本中间件(识别「大概率真前端」)+ 按 IP 限流(兜住总量)。 +// - Enabled=false 或 Secret 为空时本中间件为 noop(不拦截),便于灰度上线与回滚。 +func ClientSign(cfg config.ClientSignConfig) gin.HandlerFunc { + if !cfg.Enabled || cfg.Secret == "" { + return func(c *gin.Context) { c.Next() } + } + ttl := cfg.TTLSeconds + if ttl <= 0 { + ttl = 30 + } + secret := []byte(cfg.Secret) + return func(c *gin.Context) { + sig := c.GetHeader(hdrSign) + ts := c.GetHeader(hdrTs) + nonce := c.GetHeader(hdrNonce) + if sig == "" || ts == "" || nonce == "" { + response.AbortError(c, http.StatusUnauthorized, "missing client signature") + return + } + ti, err := strconv.ParseInt(ts, 10, 64) + if err != nil { + response.AbortError(c, http.StatusUnauthorized, "invalid signature timestamp") + return + } + now := time.Now().Unix() + if diff := now - ti; diff < -int64(ttl) || diff > int64(ttl) { + response.AbortError(c, http.StatusUnauthorized, "expired signature") + return + } + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(strings.Join([]string{ + c.Request.Method, + c.Request.URL.Path, + c.Request.URL.RawQuery, + ts, + nonce, + }, "\n"))) + expected := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(expected), []byte(sig)) { + response.AbortError(c, http.StatusUnauthorized, "bad client signature") + return + } + c.Next() + } +} diff --git a/internal/model/street_snap.go b/internal/model/street_snap.go new file mode 100644 index 0000000..7d5cf40 --- /dev/null +++ b/internal/model/street_snap.go @@ -0,0 +1,37 @@ +package model + +// StreetSnap 街拍主表(对应 db_dev.street_snap)。 +// +// 字段对齐 db_dev 约定:INT UNSIGNED 主键、int unsigned 时间戳、is_deleted 软删、varchar(255)。 +// 与 brand_runway 双表同构,但**无 brand_id**(街拍不关联品牌,模块自治)。 +type StreetSnap struct { + ID uint32 `gorm:"primaryKey;column:id" json:"id"` + Title string `gorm:"column:title" json:"title"` + Year uint16 `gorm:"column:year" json:"year"` + Cover string `gorm:"column:cover" json:"cover"` + SourceURL string `gorm:"column:source_url" json:"source_url"` + ImageCount uint16 `gorm:"column:image_count" json:"image_count"` + IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` + CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` + UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` +} + +// TableName 指定主表名。 +func (StreetSnap) TableName() string { return "street_snap" } + +// StreetSnapImage 街拍图片表(对应 db_dev.street_snap_images)。 +// +// 仿 brand_runway_images,但把 runway_id 改名为 snap_id,去掉 brand_id(街拍无品牌关联)。 +type StreetSnapImage struct { + ID uint32 `gorm:"primaryKey;column:id" json:"id"` + SnapID uint32 `gorm:"column:snap_id" json:"snap_id"` + Image string `gorm:"column:image" json:"image"` + Name string `gorm:"column:name" json:"name"` + SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"` + IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` + CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` + UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` +} + +// TableName 指定图片明细表名。 +func (StreetSnapImage) TableName() string { return "street_snap_images" } diff --git a/internal/repository/street_snap_repository.go b/internal/repository/street_snap_repository.go new file mode 100644 index 0000000..2bfbac7 --- /dev/null +++ b/internal/repository/street_snap_repository.go @@ -0,0 +1,142 @@ +package repository + +import ( + "context" + "errors" + + "fashionapi/internal/dto" + "fashionapi/internal/model" + + "gorm.io/gorm" +) + +// StreetSnapRepository 街拍数据访问接口。 +type StreetSnapRepository interface { + // List 按筛选条件分页查询街拍,同时返回符合条件的总数。 + List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) + // FindByID 查询单条街拍;不存在时返回 ErrNotFound。 + FindByID(ctx context.Context, id string) (*model.StreetSnap, error) + // ListImages 查询某条街拍的全部图片,按排序值升序。 + ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) + // ImagesBySnapIDs 批量查询多条街拍的图片,单次 IN 查询避免 N+1。 + ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error) + // Popular 按图片数降序返回热度前 N 个街拍,供 SSG 首页热门街拍使用。 + Popular(ctx context.Context, limit int) ([]model.StreetSnap, error) +} + +type streetSnapRepository struct { + db *gorm.DB +} + +// NewStreetSnapRepository 创建街拍仓储。 +func NewStreetSnapRepository(db *gorm.DB) StreetSnapRepository { + return &streetSnapRepository{db: db} +} + +// filterScope 把查询条件编译为 GORM Scope(白名单化,杜绝 SQL 注入)。 +func snapFilterScope(q dto.StreetSnapQuery) func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + db = db.Where("is_deleted = 0") + if q.Year != 0 { + db = db.Where("year = ?", q.Year) + } + return db + } +} + +// snapOrderBy 把 sort 参数映射为 ORDER BY(白名单,杜绝注入)。 +func snapOrderBy(sort string) string { + switch sort { + case "year_asc": + return "street_snap.year ASC, street_snap.id ASC" + case "year_desc": + return "street_snap.year DESC, street_snap.id DESC" + default: // image_count + return "street_snap.image_count DESC, street_snap.id DESC" + } +} + +func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) { + scope := snapFilterScope(q) + + var total int64 + if err := r.db.WithContext(ctx). + Model(&model.StreetSnap{}). + Scopes(scope). + Count(&total).Error; err != nil { + return nil, 0, err + } + if total == 0 { + return []model.StreetSnap{}, 0, nil + } + + var rows []model.StreetSnap + if err := r.db.WithContext(ctx). + Model(&model.StreetSnap{}). + Scopes(scope). + Order(snapOrderBy(q.Sort)). + Offset(q.Offset()). + Limit(q.Size). + Find(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} + +func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.StreetSnap, error) { + var snap model.StreetSnap + err := r.db.WithContext(ctx). + Where("id = ? AND is_deleted = 0", id). + First(&snap).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return &snap, nil +} + +func (r *streetSnapRepository) ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) { + var imgs []model.StreetSnapImage + err := r.db.WithContext(ctx). + Where("snap_id = ? AND is_deleted = 0", snapID). + Order("sort_order ASC, id ASC"). + Find(&imgs).Error + return imgs, err +} + +func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error) { + result := make(map[uint32][]model.StreetSnapImage, len(ids)) + if len(ids) == 0 { + return result, nil + } + + var imgs []model.StreetSnapImage + err := r.db.WithContext(ctx). + Select("snap_id, image, name, sort_order"). + Where("snap_id IN ? AND is_deleted = 0", ids). + Order("sort_order ASC, id ASC"). + Find(&imgs).Error + if err != nil { + return nil, err + } + for _, im := range imgs { + result[im.SnapID] = append(result[im.SnapID], im) + } + return result, nil +} + +// IDs 返回全部未删除街拍的 id(升序),供 SSG 构建期枚举详情页路径。 +// Popular 按图片数降序返回热度前 N 个街拍(SSG 首页热门街拍用)。 +func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.StreetSnap, error) { + var rows []model.StreetSnap + if err := r.db.WithContext(ctx). + Where("is_deleted = 0"). + Order("image_count DESC, id DESC"). + Limit(limit). + Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} diff --git a/internal/service/street_snap_service.go b/internal/service/street_snap_service.go new file mode 100644 index 0000000..3ad4d8d --- /dev/null +++ b/internal/service/street_snap_service.go @@ -0,0 +1,134 @@ +package service + +import ( + "context" + "errors" + "net/http" + + "fashionapi/internal/dto" + "fashionapi/internal/model" + "fashionapi/internal/pkg/hashid" + "fashionapi/internal/repository" +) + +// StreetSnapService 街拍业务接口。 +type StreetSnapService interface { + List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) + Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) + // Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。 + Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) +} + +type streetSnapService struct { + repo repository.StreetSnapRepository +} + +// NewStreetSnapService 创建街拍服务。 +func NewStreetSnapService(repo repository.StreetSnapRepository) StreetSnapService { + return &streetSnapService{repo: repo} +} + +// ErrStreetSnapNotFound 街拍不存在。 +var ErrStreetSnapNotFound = NewError(http.StatusNotFound, "street snap not found") + +// List 查询街拍列表,并按需附带每条的前 N 张图。 +func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) { + q.Normalize() + + rows, total, err := s.repo.List(ctx, q) + if err != nil { + return nil, 0, internalErr(err.Error()) + } + + list := make([]dto.PublicStreetSnap, 0, len(rows)) + ids := make([]uint32, 0, len(rows)) + for _, r := range rows { + ids = append(ids, r.ID) + list = append(list, dto.PublicStreetSnap{ + UID: hashid.Encode(r.ID), + Title: r.Title, + Cover: r.Cover, + ImageCount: r.ImageCount, + }) + } + + if q.Images > 0 && len(list) > 0 { + if err := s.attachImages(ctx, list, ids, q.Images); err != nil { + return nil, 0, internalErr(err.Error()) + } + } + return list, total, nil +} + +// attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐条请求详情。 +func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicStreetSnap, ids []uint32, limit int) error { + grouped, err := s.repo.ImagesBySnapIDs(ctx, ids) + if err != nil { + return err + } + for i := range list { + imgs, ok := grouped[ids[i]] + if !ok { + continue + } + if len(imgs) > limit { + imgs = imgs[:limit] + } + list[i].Images = toPublicSnapImages(imgs) + } + return nil +} + +// Detail 查询街拍详情,含完整图片集。 +func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) { + snap, err := s.repo.FindByID(ctx, id) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil, ErrStreetSnapNotFound + } + return nil, internalErr(err.Error()) + } + + imgs, err := s.repo.ListImages(ctx, id) + if err != nil { + return nil, internalErr(err.Error()) + } + + return &dto.PublicStreetSnapDetail{ + UID: hashid.Encode(snap.ID), + Title: snap.Title, + Cover: snap.Cover, + Images: toPublicSnapImages(imgs), + }, nil +} + +// IDs 返回全部街拍的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。 +// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。 +func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) { + rows, err := s.repo.Popular(ctx, limit) + if err != nil { + return nil, internalErr(err.Error()) + } + list := make([]dto.PublicStreetSnap, 0, len(rows)) + for _, r := range rows { + list = append(list, dto.PublicStreetSnap{ + UID: hashid.Encode(r.ID), + Title: r.Title, + Cover: r.Cover, + ImageCount: r.ImageCount, + }) + } + return list, nil +} + +// toPublicSnapImages 把图片实体映射为对外结构(只保留 url 与名称)。 +func toPublicSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage { + if len(imgs) == 0 { + return nil + } + out := make([]dto.PublicArticleImage, 0, len(imgs)) + for _, im := range imgs { + out = append(out, dto.PublicArticleImage{Image: im.Image, Name: im.Name}) + } + return out +}