This commit is contained in:
toom1996
2026-09-03 00:32:52 +08:00
parent 1fd9c48f58
commit ffe84efc4c
9 changed files with 143 additions and 39 deletions

View File

@ -58,8 +58,12 @@ func (q ArticleQuery) Offset() int { return (q.Page - 1) * q.Size }
// ---------- 响应 ---------- // ---------- 响应 ----------
// PublicArticleImage 对外展示用的图片结构(仅 url + 名称)。 // PublicArticleImage 对外展示用的图片结构。
//
// ID 是图片自身的编码 id(hashid,i=走秀单图 / j=街拍单图),供「单图收藏」定位到具体某一张;
// 此前只有 image + name,前端无法稳定标识单张图片,故补充该字段。
type PublicArticleImage struct { type PublicArticleImage struct {
ID string `json:"id"`
Image string `json:"image"` Image string `json:"image"`
Name string `json:"name"` Name string `json:"name"`
} }

View File

@ -2,7 +2,16 @@ package dto
// ---------- 收藏 ---------- // ---------- 收藏 ----------
// 收藏粒度:与 model.Favorite.Kind 对应。
const (
FavoriteKindGallery = "gallery" // 整篇图集(runway / street)
FavoriteKindImage = "image" // 单张图片(runway_image / snap_image)
)
// FavoriteItem 对外返回的收藏项:id 即 target_uid,便于前端直接比对卡片 id。 // FavoriteItem 对外返回的收藏项:id 即 target_uid,便于前端直接比对卡片 id。
//
// kind=image 时 image_url 是这一张图、parent_uid 是所属图集,前端用这两项渲染与回跳;
// kind=gallery 时沿用 cover / title / brand 快照。
type FavoriteItem struct { type FavoriteItem struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` Type string `json:"type"`
@ -10,15 +19,23 @@ type FavoriteItem struct {
Cover string `json:"cover"` Cover string `json:"cover"`
Brand string `json:"brand"` Brand string `json:"brand"`
AddedAt uint32 `json:"added_at"` AddedAt uint32 `json:"added_at"`
Kind string `json:"kind"`
ImageURL string `json:"image_url"`
ParentUID string `json:"parent_uid"`
} }
// FavoriteAdd 添加收藏请求体。target_uid 为前端卡片的 it.id(编码串)。 // FavoriteAdd 添加收藏请求体。target_uid 为前端卡片的 id(图集或图片的编码串)。
type FavoriteAdd struct { type FavoriteAdd struct {
TargetType string `json:"target_type" binding:"required"` // runway | street TargetType string `json:"target_type" binding:"required"` // runway | street | runway_image | snap_image
TargetUID string `json:"target_uid" binding:"required"` TargetUID string `json:"target_uid" binding:"required"`
Title string `json:"title"` Title string `json:"title"`
Cover string `json:"cover"` Cover string `json:"cover"`
Brand string `json:"brand"` Brand string `json:"brand"`
// 图片收藏可选字段。kind 留空时由 target_type 推断(*_image → image,否则 gallery)。
Kind string `json:"kind"`
ImageURL string `json:"image_url"`
ParentUID string `json:"parent_uid"`
} }
// FavoriteCheck 批量查询哪些 target_uid 已收藏(列表页打勾用,与收藏总量解耦)。 // FavoriteCheck 批量查询哪些 target_uid 已收藏(列表页打勾用,与收藏总量解耦)。

View File

@ -24,7 +24,8 @@ func NewFavoriteHandler(favs service.FavoriteService) *FavoriteHandler {
// List 当前用户收藏(服务端分页)。 // List 当前用户收藏(服务端分页)。
// //
// GET /api/v1/auth/favorites?page=1&per_page=24(per_page 上限 100) // GET /api/v1/auth/favorites?page=1&per_page=24&kind=(per_page 上限 100)
// kind 留空=不限粒度;gallery=只要图集收藏;image=只要单图收藏。
// 成功:200 { data: { items:[...], total, last_page } } // 成功:200 { data: { items:[...], total, last_page } }
func (h *FavoriteHandler) List(c *gin.Context) { func (h *FavoriteHandler) List(c *gin.Context) {
userID, ok := middleware.UserIDFrom(c) userID, ok := middleware.UserIDFrom(c)
@ -40,7 +41,12 @@ func (h *FavoriteHandler) List(c *gin.Context) {
if perPage < 1 || perPage > 100 { if perPage < 1 || perPage > 100 {
perPage = 24 perPage = 24
} }
items, total, err := h.favs.ListPaged(c.Request.Context(), userID, page, perPage) kind := c.Query("kind")
if kind != "" && kind != dto.FavoriteKindGallery && kind != dto.FavoriteKindImage {
response.Error(c, http.StatusBadRequest, "invalid kind")
return
}
items, total, err := h.favs.ListPaged(c.Request.Context(), userID, page, perPage, kind)
if err != nil { if err != nil {
fail(c, err) fail(c, err)
return return

View File

@ -2,18 +2,28 @@ package model
// Favorite 用户收藏(服务端持久化,按用户隔离)。 // Favorite 用户收藏(服务端持久化,按用户隔离)。
// //
// 存储「对外编码 uid」(如 runway 的 r= 前缀串、street 的 s= 前缀串)而非数字主键, // 存储「对外编码 uid」而非数字主键,这样无需 decode 即可与前端卡片的 id 直接对齐,
// 这样无需 decode 即可与前端卡片的 it.id 直接对齐,取消收藏时也只靠该串匹配。 // 取消收藏时也只靠该串匹配。两种粒度共用一张表,由 kind 区分:
// title / cover / brand 为收藏时的快照,供个人中心直接渲染,不必回查原表。 // - kind=gallery(默认):收藏整篇图集,target_uid 是图集 uid(r/s 前缀),
// title / cover / brand 为收藏时的快照,供个人中心直接渲染,不必回查原表;
// - kind=image:收藏单张图片,target_uid 是图片自身的编码 id(i/j 前缀),
// image_url 存这张图,parent_uid 指向所属图集(用于从收藏回跳原图集)。
//
// 两种粒度的 uid 前缀互不冲突(图集 r/s,图片 i/j),因此唯一键 (user_id, target_uid)
// 无需调整即可同时约束两种收藏。
type Favorite struct { type Favorite struct {
ID uint32 `gorm:"primaryKey;column:id" json:"id"` ID uint32 `gorm:"primaryKey;column:id" json:"id"`
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
UserID uint32 `gorm:"column:user_id;index:idx_user;not null" json:"user_id"` UserID uint32 `gorm:"column:user_id;index:idx_user;not null" json:"user_id"`
TargetType string `gorm:"column:target_type;size:16;not null" json:"target_type"` // runway | street TargetType string `gorm:"column:target_type;size:16;not null" json:"target_type"` // runway | street | runway_image | snap_image
TargetUID string `gorm:"column:target_uid;size:64;not null;uniqueIndex:uniq_user_target" json:"target_uid"` TargetUID string `gorm:"column:target_uid;size:64;not null;uniqueIndex:uniq_user_target" json:"target_uid"`
Title string `gorm:"column:title;size:255" json:"title"` Title string `gorm:"column:title;size:255" json:"title"`
Cover string `gorm:"column:cover;size:512" json:"cover"` Cover string `gorm:"column:cover;size:512" json:"cover"`
Brand string `gorm:"column:brand;size:255" json:"brand"` Brand string `gorm:"column:brand;size:255" json:"brand"`
Kind string `gorm:"column:kind;size:16;not null;default:gallery" json:"kind"` // gallery | image
ImageURL string `gorm:"column:image_url;size:512" json:"image_url"` // kind=image 时:这一张图
ParentUID string `gorm:"column:parent_uid;size:64" json:"parent_uid"` // kind=image 时:所属图集 uid
} }
// TableName 指定表名。 // TableName 指定表名。

View File

@ -130,6 +130,12 @@ func Decode(s string) (uint32, error) {
const ( const (
TypeRunway = 'r' // 走秀 / 文章 lookbook(brand_runway 表) TypeRunway = 'r' // 走秀 / 文章 lookbook(brand_runway 表)
TypeSnap = 's' // 街拍(street_snap 表) TypeSnap = 's' // 街拍(street_snap 表)
// 图片级前缀:单张图片也要有对外标识(图片收藏用)。
// 与图集前缀(r/s)互不冲突,因此同一数字主键在图集与图片下得到不同编码串,
// 收藏表可用 target_uid 同时容纳两种粒度而不撞车;后续也能凭前缀反查所属表。
TypeRunwayImage = 'i' // 走秀单图(brand_runway_images 表)
TypeSnapImage = 'j' // 街拍单图(street_snap_images 表)
) )
// EncodeTyped 在编码串前拼一个类型字符前缀(如 "r"+base62 串)。前缀字符取自 base62 字母表, // EncodeTyped 在编码串前拼一个类型字符前缀(如 "r"+base62 串)。前缀字符取自 base62 字母表,

View File

@ -10,11 +10,13 @@ import (
) )
// FavoriteRepository 收藏数据访问接口。 // FavoriteRepository 收藏数据访问接口。
//
// kind 用于区分收藏粒度(gallery=整篇图集 / image=单张图片);传空表示不限粒度。
type FavoriteRepository interface { type FavoriteRepository interface {
// ListPaged 分页取某用户收藏,按收藏时间倒序。 // ListPaged 分页取某用户收藏,按收藏时间倒序。
ListPaged(ctx context.Context, userID uint32, page, perPage int) ([]model.Favorite, error) ListPaged(ctx context.Context, userID uint32, page, perPage int, kind string) ([]model.Favorite, error)
// CountByUser 取某用户收藏总数(分页用)。 // CountByUser 取某用户收藏总数(分页用)。
CountByUser(ctx context.Context, userID uint32) (int64, error) CountByUser(ctx context.Context, userID uint32, kind string) (int64, error)
// Add 新增收藏;同用户同 target 已存在时幂等忽略(ON DUPLICATE KEY DO NOTHING)。 // Add 新增收藏;同用户同 target 已存在时幂等忽略(ON DUPLICATE KEY DO NOTHING)。
Add(ctx context.Context, f *model.Favorite) error Add(ctx context.Context, f *model.Favorite) error
// Remove 删除某用户对某个 target 的收藏。 // Remove 删除某用户对某个 target 的收藏。
@ -32,14 +34,17 @@ func NewFavoriteRepository(db *gorm.DB) FavoriteRepository {
return &favoriteRepository{db: db} return &favoriteRepository{db: db}
} }
func (r *favoriteRepository) ListPaged(ctx context.Context, userID uint32, page, perPage int) ([]model.Favorite, error) { func (r *favoriteRepository) ListPaged(ctx context.Context, userID uint32, page, perPage int, kind string) ([]model.Favorite, error) {
var rows []model.Favorite var rows []model.Favorite
offset := (page - 1) * perPage offset := (page - 1) * perPage
if offset < 0 { if offset < 0 {
offset = 0 offset = 0
} }
err := r.db.WithContext(ctx). q := r.db.WithContext(ctx).Where("user_id = ?", userID)
Where("user_id = ?", userID). if kind != "" {
q = q.Where("kind = ?", kind)
}
err := q.
Order("created_at DESC"). Order("created_at DESC").
Offset(offset). Offset(offset).
Limit(perPage). Limit(perPage).
@ -47,12 +52,15 @@ func (r *favoriteRepository) ListPaged(ctx context.Context, userID uint32, page,
return rows, err return rows, err
} }
func (r *favoriteRepository) CountByUser(ctx context.Context, userID uint32) (int64, error) { func (r *favoriteRepository) CountByUser(ctx context.Context, userID uint32, kind string) (int64, error) {
var n int64 var n int64
err := r.db.WithContext(ctx). q := r.db.WithContext(ctx).
Model(&model.Favorite{}). Model(&model.Favorite{}).
Where("user_id = ?", userID). Where("user_id = ?", userID)
Count(&n).Error if kind != "" {
q = q.Where("kind = ?", kind)
}
err := q.Count(&n).Error
return n, err return n, err
} }

View File

@ -122,14 +122,19 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string) (
} }
// IDs 返回全部文章的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。 // IDs 返回全部文章的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。
// toPublicImages 把图片实体映射为对外结构(只保留 url 与名称)。 // toPublicImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
// ID 用 TypeRunwayImage 前缀编码,与文章 uid(r 前缀)区分开,供单图收藏定位。
func toPublicImages(imgs []model.BrandRunwayImage) []dto.PublicArticleImage { func toPublicImages(imgs []model.BrandRunwayImage) []dto.PublicArticleImage {
if len(imgs) == 0 { if len(imgs) == 0 {
return nil return nil
} }
out := make([]dto.PublicArticleImage, 0, len(imgs)) out := make([]dto.PublicArticleImage, 0, len(imgs))
for _, im := range imgs { for _, im := range imgs {
out = append(out, dto.PublicArticleImage{Image: im.Image, Name: im.Name}) out = append(out, dto.PublicArticleImage{
ID: hashid.EncodeTyped(hashid.TypeRunwayImage, im.ID),
Image: im.Image,
Name: im.Name,
})
} }
return out return out
} }

View File

@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"strings"
"time" "time"
"fashionapi/internal/dto" "fashionapi/internal/dto"
@ -12,7 +13,8 @@ import (
// FavoriteService 收藏业务接口。所有方法均要求调用方已通过中间件解析出 userID。 // FavoriteService 收藏业务接口。所有方法均要求调用方已通过中间件解析出 userID。
type FavoriteService interface { type FavoriteService interface {
// ListPaged 分页取当前用户收藏(服务端分页,与收藏总量解耦)。 // ListPaged 分页取当前用户收藏(服务端分页,与收藏总量解耦)。
ListPaged(ctx context.Context, userID uint32, page, perPage int) ([]dto.FavoriteItem, int64, error) // kind 传空表示不限粒度;传 gallery / image 只取对应粒度。
ListPaged(ctx context.Context, userID uint32, page, perPage int, kind string) ([]dto.FavoriteItem, int64, error)
// Add 新增收藏(幂等)。 // Add 新增收藏(幂等)。
Add(ctx context.Context, userID uint32, req dto.FavoriteAdd) (*dto.FavoriteItem, error) Add(ctx context.Context, userID uint32, req dto.FavoriteAdd) (*dto.FavoriteItem, error)
// Remove 删除收藏。 // Remove 删除收藏。
@ -30,12 +32,12 @@ func NewFavoriteService(favs repository.FavoriteRepository) FavoriteService {
return &favoriteService{favs: favs} return &favoriteService{favs: favs}
} }
func (s *favoriteService) ListPaged(ctx context.Context, userID uint32, page, perPage int) ([]dto.FavoriteItem, int64, error) { func (s *favoriteService) ListPaged(ctx context.Context, userID uint32, page, perPage int, kind string) ([]dto.FavoriteItem, int64, error) {
rows, err := s.favs.ListPaged(ctx, userID, page, perPage) rows, err := s.favs.ListPaged(ctx, userID, page, perPage, kind)
if err != nil { if err != nil {
return nil, 0, internalErr(err.Error()) return nil, 0, internalErr(err.Error())
} }
total, err := s.favs.CountByUser(ctx, userID) total, err := s.favs.CountByUser(ctx, userID, kind)
if err != nil { if err != nil {
return nil, 0, internalErr(err.Error()) return nil, 0, internalErr(err.Error())
} }
@ -47,12 +49,26 @@ func (s *favoriteService) ListPaged(ctx context.Context, userID uint32, page, pe
} }
func (s *favoriteService) Add(ctx context.Context, userID uint32, req dto.FavoriteAdd) (*dto.FavoriteItem, error) { func (s *favoriteService) Add(ctx context.Context, userID uint32, req dto.FavoriteAdd) (*dto.FavoriteItem, error) {
if req.TargetType != "runway" && req.TargetType != "street" { if !validTargetType(req.TargetType) {
return nil, badRequest("invalid target_type") return nil, badRequest("invalid target_type")
} }
if req.TargetUID == "" { if req.TargetUID == "" {
return nil, badRequest("target_uid required") return nil, badRequest("target_uid required")
} }
// 粒度:请求未显式指定时按 target_type 推断(*_image 即图片收藏)。
kind := req.Kind
if kind == "" {
kind = inferKind(req.TargetType)
}
if kind != dto.FavoriteKindGallery && kind != dto.FavoriteKindImage {
return nil, badRequest("invalid kind")
}
// 图片收藏必须带上图片地址,否则个人中心无从渲染。
if kind == dto.FavoriteKindImage && req.ImageURL == "" {
return nil, badRequest("image_url required for image favorite")
}
row := &model.Favorite{ row := &model.Favorite{
UserID: userID, UserID: userID,
TargetType: req.TargetType, TargetType: req.TargetType,
@ -60,6 +76,9 @@ func (s *favoriteService) Add(ctx context.Context, userID uint32, req dto.Favori
Title: req.Title, Title: req.Title,
Cover: req.Cover, Cover: req.Cover,
Brand: req.Brand, Brand: req.Brand,
Kind: kind,
ImageURL: req.ImageURL,
ParentUID: req.ParentUID,
CreatedAt: uint32(time.Now().Unix()), CreatedAt: uint32(time.Now().Unix()),
} }
if err := s.favs.Add(ctx, row); err != nil { if err := s.favs.Add(ctx, row); err != nil {
@ -90,7 +109,28 @@ func (s *favoriteService) Check(ctx context.Context, userID uint32, ids []string
return got, nil return got, nil
} }
// validTargetType 允许的收藏目标类型:图集两种 + 单图两种。
func validTargetType(t string) bool {
switch t {
case "runway", "street", "runway_image", "snap_image":
return true
}
return false
}
// inferKind 由 target_type 推断收藏粒度。
func inferKind(targetType string) string {
if strings.HasSuffix(targetType, "_image") {
return dto.FavoriteKindImage
}
return dto.FavoriteKindGallery
}
func toFavoriteItem(r model.Favorite) dto.FavoriteItem { func toFavoriteItem(r model.Favorite) dto.FavoriteItem {
kind := r.Kind
if kind == "" {
kind = dto.FavoriteKindGallery // 历史数据无 kind 列值,一律视为图集
}
return dto.FavoriteItem{ return dto.FavoriteItem{
ID: r.TargetUID, ID: r.TargetUID,
Type: r.TargetType, Type: r.TargetType,
@ -98,5 +138,8 @@ func toFavoriteItem(r model.Favorite) dto.FavoriteItem {
Cover: r.Cover, Cover: r.Cover,
Brand: r.Brand, Brand: r.Brand,
AddedAt: r.CreatedAt, AddedAt: r.CreatedAt,
Kind: kind,
ImageURL: r.ImageURL,
ParentUID: r.ParentUID,
} }
} }

View File

@ -137,14 +137,19 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
return list, nil return list, nil
} }
// toPublicSnapImages 把图片实体映射为对外结构(只保留 url 与名称)。 // toPublicSnapImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
// ID 用 TypeSnapImage 前缀编码,与街拍 uid(s 前缀)区分开,供单图收藏定位。
func toPublicSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage { func toPublicSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
if len(imgs) == 0 { if len(imgs) == 0 {
return nil return nil
} }
out := make([]dto.PublicArticleImage, 0, len(imgs)) out := make([]dto.PublicArticleImage, 0, len(imgs))
for _, im := range imgs { for _, im := range imgs {
out = append(out, dto.PublicArticleImage{Image: im.Image, Name: im.Name}) out = append(out, dto.PublicArticleImage{
ID: hashid.EncodeTyped(hashid.TypeSnapImage, im.ID),
Image: im.Image,
Name: im.Name,
})
} }
return out return out
} }