diff --git a/internal/dto/article.go b/internal/dto/article.go index 164c970..62e6491 100644 --- a/internal/dto/article.go +++ b/internal/dto/article.go @@ -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 { + ID string `json:"id"` Image string `json:"image"` Name string `json:"name"` } diff --git a/internal/dto/favorite.go b/internal/dto/favorite.go index 1f646a2..1f1036f 100644 --- a/internal/dto/favorite.go +++ b/internal/dto/favorite.go @@ -2,23 +2,40 @@ package dto // ---------- 收藏 ---------- +// 收藏粒度:与 model.Favorite.Kind 对应。 +const ( + FavoriteKindGallery = "gallery" // 整篇图集(runway / street) + FavoriteKindImage = "image" // 单张图片(runway_image / snap_image) +) + // FavoriteItem 对外返回的收藏项:id 即 target_uid,便于前端直接比对卡片 id。 +// +// kind=image 时 image_url 是这一张图、parent_uid 是所属图集,前端用这两项渲染与回跳; +// kind=gallery 时沿用 cover / title / brand 快照。 type FavoriteItem struct { - ID string `json:"id"` - Type string `json:"type"` - Title string `json:"title"` - Cover string `json:"cover"` - Brand string `json:"brand"` - AddedAt uint32 `json:"added_at"` + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Cover string `json:"cover"` + Brand string `json:"brand"` + 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 { - 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"` Title string `json:"title"` Cover string `json:"cover"` 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 已收藏(列表页打勾用,与收藏总量解耦)。 diff --git a/internal/handler/favorite_handler.go b/internal/handler/favorite_handler.go index 0db3af4..0e20451 100644 --- a/internal/handler/favorite_handler.go +++ b/internal/handler/favorite_handler.go @@ -24,7 +24,8 @@ func NewFavoriteHandler(favs service.FavoriteService) *FavoriteHandler { // 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 } } func (h *FavoriteHandler) List(c *gin.Context) { userID, ok := middleware.UserIDFrom(c) @@ -40,7 +41,12 @@ func (h *FavoriteHandler) List(c *gin.Context) { if perPage < 1 || perPage > 100 { 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 { fail(c, err) return diff --git a/internal/model/favorite.go b/internal/model/favorite.go index 7fabada..fdb5d24 100644 --- a/internal/model/favorite.go +++ b/internal/model/favorite.go @@ -2,18 +2,28 @@ package model // Favorite 用户收藏(服务端持久化,按用户隔离)。 // -// 存储「对外编码 uid」(如 runway 的 r= 前缀串、street 的 s= 前缀串)而非数字主键, -// 这样无需 decode 即可与前端卡片的 it.id 直接对齐,取消收藏时也只靠该串匹配。 -// title / cover / brand 为收藏时的快照,供个人中心直接渲染,不必回查原表。 +// 存储「对外编码 uid」而非数字主键,这样无需 decode 即可与前端卡片的 id 直接对齐, +// 取消收藏时也只靠该串匹配。两种粒度共用一张表,由 kind 区分: +// - 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 { ID uint32 `gorm:"primaryKey;column:id" json:"id"` CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` 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"` Title string `gorm:"column:title;size:255" json:"title"` Cover string `gorm:"column:cover;size:512" json:"cover"` 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 指定表名。 diff --git a/internal/pkg/hashid/hashid.go b/internal/pkg/hashid/hashid.go index 57abccb..aced7c7 100644 --- a/internal/pkg/hashid/hashid.go +++ b/internal/pkg/hashid/hashid.go @@ -130,6 +130,12 @@ func Decode(s string) (uint32, error) { const ( TypeRunway = 'r' // 走秀 / 文章 lookbook(brand_runway 表) TypeSnap = 's' // 街拍(street_snap 表) + + // 图片级前缀:单张图片也要有对外标识(图片收藏用)。 + // 与图集前缀(r/s)互不冲突,因此同一数字主键在图集与图片下得到不同编码串, + // 收藏表可用 target_uid 同时容纳两种粒度而不撞车;后续也能凭前缀反查所属表。 + TypeRunwayImage = 'i' // 走秀单图(brand_runway_images 表) + TypeSnapImage = 'j' // 街拍单图(street_snap_images 表) ) // EncodeTyped 在编码串前拼一个类型字符前缀(如 "r"+base62 串)。前缀字符取自 base62 字母表, diff --git a/internal/repository/favorite_repository.go b/internal/repository/favorite_repository.go index 768207a..845ec74 100644 --- a/internal/repository/favorite_repository.go +++ b/internal/repository/favorite_repository.go @@ -10,11 +10,13 @@ import ( ) // FavoriteRepository 收藏数据访问接口。 +// +// kind 用于区分收藏粒度(gallery=整篇图集 / image=单张图片);传空表示不限粒度。 type FavoriteRepository interface { // 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(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(ctx context.Context, f *model.Favorite) error // Remove 删除某用户对某个 target 的收藏。 @@ -32,14 +34,17 @@ func NewFavoriteRepository(db *gorm.DB) FavoriteRepository { 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 offset := (page - 1) * perPage if offset < 0 { offset = 0 } - err := r.db.WithContext(ctx). - Where("user_id = ?", userID). + q := r.db.WithContext(ctx).Where("user_id = ?", userID) + if kind != "" { + q = q.Where("kind = ?", kind) + } + err := q. Order("created_at DESC"). Offset(offset). Limit(perPage). @@ -47,12 +52,15 @@ func (r *favoriteRepository) ListPaged(ctx context.Context, userID uint32, page, 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 - err := r.db.WithContext(ctx). + q := r.db.WithContext(ctx). Model(&model.Favorite{}). - Where("user_id = ?", userID). - Count(&n).Error + Where("user_id = ?", userID) + if kind != "" { + q = q.Where("kind = ?", kind) + } + err := q.Count(&n).Error return n, err } diff --git a/internal/service/article_service.go b/internal/service/article_service.go index a371ed6..0addedc 100644 --- a/internal/service/article_service.go +++ b/internal/service/article_service.go @@ -122,14 +122,19 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string) ( } // IDs 返回全部文章的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。 -// toPublicImages 把图片实体映射为对外结构(只保留 url 与名称)。 +// toPublicImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。 +// ID 用 TypeRunwayImage 前缀编码,与文章 uid(r 前缀)区分开,供单图收藏定位。 func toPublicImages(imgs []model.BrandRunwayImage) []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}) + out = append(out, dto.PublicArticleImage{ + ID: hashid.EncodeTyped(hashid.TypeRunwayImage, im.ID), + Image: im.Image, + Name: im.Name, + }) } return out } diff --git a/internal/service/favorite_service.go b/internal/service/favorite_service.go index eac97c1..bc0a55b 100644 --- a/internal/service/favorite_service.go +++ b/internal/service/favorite_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "strings" "time" "fashionapi/internal/dto" @@ -12,7 +13,8 @@ import ( // FavoriteService 收藏业务接口。所有方法均要求调用方已通过中间件解析出 userID。 type FavoriteService interface { // 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(ctx context.Context, userID uint32, req dto.FavoriteAdd) (*dto.FavoriteItem, error) // Remove 删除收藏。 @@ -30,12 +32,12 @@ func NewFavoriteService(favs repository.FavoriteRepository) FavoriteService { return &favoriteService{favs: favs} } -func (s *favoriteService) ListPaged(ctx context.Context, userID uint32, page, perPage int) ([]dto.FavoriteItem, int64, error) { - rows, err := s.favs.ListPaged(ctx, userID, page, perPage) +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, kind) if err != nil { return nil, 0, internalErr(err.Error()) } - total, err := s.favs.CountByUser(ctx, userID) + total, err := s.favs.CountByUser(ctx, userID, kind) if err != nil { 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) { - if req.TargetType != "runway" && req.TargetType != "street" { + if !validTargetType(req.TargetType) { return nil, badRequest("invalid target_type") } if req.TargetUID == "" { 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{ UserID: userID, TargetType: req.TargetType, @@ -60,6 +76,9 @@ func (s *favoriteService) Add(ctx context.Context, userID uint32, req dto.Favori Title: req.Title, Cover: req.Cover, Brand: req.Brand, + Kind: kind, + ImageURL: req.ImageURL, + ParentUID: req.ParentUID, CreatedAt: uint32(time.Now().Unix()), } if err := s.favs.Add(ctx, row); err != nil { @@ -90,13 +109,37 @@ func (s *favoriteService) Check(ctx context.Context, userID uint32, ids []string 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 { + kind := r.Kind + if kind == "" { + kind = dto.FavoriteKindGallery // 历史数据无 kind 列值,一律视为图集 + } return dto.FavoriteItem{ - ID: r.TargetUID, - Type: r.TargetType, - Title: r.Title, - Cover: r.Cover, - Brand: r.Brand, - AddedAt: r.CreatedAt, + ID: r.TargetUID, + Type: r.TargetType, + Title: r.Title, + Cover: r.Cover, + Brand: r.Brand, + AddedAt: r.CreatedAt, + Kind: kind, + ImageURL: r.ImageURL, + ParentUID: r.ParentUID, } } diff --git a/internal/service/street_snap_service.go b/internal/service/street_snap_service.go index dabfb01..cabf4d2 100644 --- a/internal/service/street_snap_service.go +++ b/internal/service/street_snap_service.go @@ -137,14 +137,19 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi return list, nil } -// toPublicSnapImages 把图片实体映射为对外结构(只保留 url 与名称)。 +// toPublicSnapImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。 +// ID 用 TypeSnapImage 前缀编码,与街拍 uid(s 前缀)区分开,供单图收藏定位。 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}) + out = append(out, dto.PublicArticleImage{ + ID: hashid.EncodeTyped(hashid.TypeSnapImage, im.ID), + Image: im.Image, + Name: im.Name, + }) } return out }