update
This commit is contained in:
@ -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"`
|
||||
}
|
||||
|
||||
@ -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 已收藏(列表页打勾用,与收藏总量解耦)。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 指定表名。
|
||||
|
||||
@ -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 字母表,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user