This commit is contained in:
toom1996
2026-08-30 11:23:17 +08:00
parent 9c3403a903
commit ec1ee702bd
6 changed files with 540 additions and 0 deletions

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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()
}
}

View File

@ -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" }

View File

@ -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
}

View File

@ -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
}