update
This commit is contained in:
@ -3,6 +3,8 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
@ -14,12 +16,28 @@ import (
|
||||
type ArticleRepository interface {
|
||||
// List 按筛选条件分页查询文章,同时返回符合条件的总数。
|
||||
List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
||||
// ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code/source_url 等管理字段。
|
||||
ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
||||
// FindByID 查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
|
||||
FindByID(ctx context.Context, id string) (*model.RunwayRow, error)
|
||||
// ListImages 查询某篇文章的全部图片,按排序值升序。
|
||||
ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
|
||||
// ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。
|
||||
ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error)
|
||||
// GetForEdit 取原始走秀记录(含全部 en/cn 字段)与图片,供后台编辑表单预填。
|
||||
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
|
||||
// UpdateRunway 审核/编辑后更新字段(白名单,杜绝任意列注入)。
|
||||
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// DeleteRunwayImage 软删单张走秀图片。
|
||||
DeleteRunwayImage(ctx context.Context, imageID uint32) error
|
||||
// GetRunwayImageKey 取单张走秀图片的七牛 key(删除前清理用);不存在返回空串。
|
||||
GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error)
|
||||
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||
SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error)
|
||||
// SetRunwayCover 更新走秀封面(同时回到图片列表首张)。
|
||||
SetRunwayCover(ctx context.Context, runwayID uint32, image string) error
|
||||
// SetRunwayDeleted 上下架:deleted=1 下架(对外不可见),0 上架。
|
||||
SetRunwayDeleted(ctx context.Context, id uint32, deleted uint8) error
|
||||
}
|
||||
|
||||
type articleRepository struct {
|
||||
@ -43,6 +61,15 @@ const articleDetailColumns = `brand_runway.id, brand_runway.title_en, brand_runw
|
||||
brand_runway.description_en, brand_runway.description_cn,
|
||||
brand_runway.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
||||
|
||||
// articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year /
|
||||
// collection_type / season_code / source_url 等管理字段(前端列表不展示这些)。
|
||||
const articleAdminColumns = `brand_runway.id, brand_runway.brand_id,
|
||||
brand_runway.title_en, brand_runway.title_cn,
|
||||
brand_runway.cover, brand_runway.image_count, brand_runway.year,
|
||||
brand_runway.collection_type, brand_runway.season, brand_runway.season_code,
|
||||
brand_runway.source_url,
|
||||
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
||||
|
||||
// brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。
|
||||
const brandJoin = "LEFT JOIN brand b ON b.id = brand_runway.brand_id AND b.is_deleted = 0"
|
||||
|
||||
@ -140,6 +167,36 @@ func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]mod
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
// ListAdmin 后台管理列表:与 List 共用筛选/排序,但投影列含管理字段。
|
||||
func (r *articleRepository) ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error) {
|
||||
scope := filterScope(q)
|
||||
|
||||
var total int64
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Scopes(scope).
|
||||
Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if total == 0 {
|
||||
return []model.RunwayRow{}, 0, nil
|
||||
}
|
||||
|
||||
var rows []model.RunwayRow
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Scopes(scope).
|
||||
Select(articleAdminColumns).
|
||||
Joins(brandJoin).
|
||||
Order(orderBy(q.Sort)).
|
||||
Offset(q.Offset()).
|
||||
Limit(q.Size).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) {
|
||||
var row model.RunwayRow
|
||||
err := r.db.WithContext(ctx).
|
||||
@ -187,4 +244,106 @@ func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IDs 返回全部未删除文章的 id(升序),供 SSG 构建期枚举详情页路径。
|
||||
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
||||
|
||||
// runwayEditable 后台可编辑的走秀字段白名单(键即列名)。
|
||||
var runwayEditable = map[string]bool{
|
||||
"title_en": true,
|
||||
"title_cn": true,
|
||||
"description_en": true,
|
||||
"description_cn": true,
|
||||
"year": true,
|
||||
"season": true,
|
||||
"collection_type": true,
|
||||
"season_code": true,
|
||||
}
|
||||
|
||||
func (r *articleRepository) GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) {
|
||||
var rw model.BrandRunway
|
||||
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&rw).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, ErrNotFound
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
imgs, err := r.ListImages(ctx, strconv.FormatUint(uint64(id), 10))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &rw, imgs, nil
|
||||
}
|
||||
|
||||
func (r *articleRepository) UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error {
|
||||
clean := map[string]any{}
|
||||
for k, v := range fields {
|
||||
if runwayEditable[k] {
|
||||
clean[k] = v
|
||||
}
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
return nil
|
||||
}
|
||||
clean["updated_at"] = uint32(time.Now().Unix())
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Where("id = ?", id).
|
||||
Updates(clean).Error
|
||||
}
|
||||
|
||||
func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayImage{}).
|
||||
Where("id = ? AND is_deleted = 0", imageID).
|
||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error
|
||||
}
|
||||
|
||||
func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error) {
|
||||
var img model.BrandRunwayImage
|
||||
err := r.db.WithContext(ctx).Where("id = ?", imageID).First(&img).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return img.Image, nil
|
||||
}
|
||||
|
||||
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用)。
|
||||
// 先取非删除图片的 key 列表返回给调用方(用于后续七牛孤儿清理),再统一置 is_deleted=1。
|
||||
func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error) {
|
||||
var imgs []model.BrandRunwayImage
|
||||
if err := r.db.WithContext(ctx).
|
||||
Select("image").
|
||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||
Find(&imgs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, 0, len(imgs))
|
||||
for _, im := range imgs {
|
||||
if im.Image != "" {
|
||||
keys = append(keys, im.Image)
|
||||
}
|
||||
}
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayImage{}).
|
||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (r *articleRepository) SetRunwayCover(ctx context.Context, runwayID uint32, image string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Where("id = ?", runwayID).
|
||||
Updates(map[string]any{"cover": image, "updated_at": uint32(time.Now().Unix())}).Error
|
||||
}
|
||||
|
||||
func (r *articleRepository) SetRunwayDeleted(ctx context.Context, id uint32, deleted uint8) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{"is_deleted": deleted, "updated_at": uint32(time.Now().Unix())}).Error
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
@ -25,6 +27,18 @@ type BrandRepository interface {
|
||||
// 供 Hot 等已知 id 列表的场景使用,避免借道 List 的 featured 旗标复用查询。
|
||||
ListByIDs(ctx context.Context, ids []uint32) ([]model.Brand, error)
|
||||
|
||||
// FindByID 按数字主键取单个品牌;不存在返回 ErrNotFound。
|
||||
FindByID(ctx context.Context, id uint32) (*model.Brand, error)
|
||||
// Create 插入品牌,返回自增主键。
|
||||
Create(ctx context.Context, b *model.Brand) (uint32, error)
|
||||
// Update 按数字主键更新品牌中英文名。
|
||||
Update(ctx context.Context, id uint32, nameEn, nameCn string) error
|
||||
|
||||
// CrawlBrands 返回所有可被爬虫抓取的品牌(is_deleted=0 且 name_en 非空)。
|
||||
// 供爬虫取任务接口使用:不限制"是否已有走秀档案",因为爬虫本就要去补全尚无档案的品牌。
|
||||
// brandID>0 时仅返回该品牌(对应爬虫 --brand 单品牌调试)。
|
||||
CrawlBrands(ctx context.Context, brandID uint32) ([]model.Brand, error)
|
||||
|
||||
// PopularWithCover 返回"热门品牌"及其代表走秀的封面/标题/文章 ID。
|
||||
// 规则:先按品牌总图片数取前 limit 个热门品牌,再为每个品牌取最新一篇走秀。
|
||||
// 返回的是仓储层裸数据(数字主键),对外编码由 service 层负责。
|
||||
@ -176,6 +190,55 @@ func (r *brandRepository) ListByIDs(ctx context.Context, ids []uint32) ([]model.
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// FindByID 按数字主键取单个品牌;不存在返回 ErrNotFound。
|
||||
func (r *brandRepository) FindByID(ctx context.Context, id uint32) (*model.Brand, error) {
|
||||
var b model.Brand
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("id = ? AND is_deleted = 0", id).
|
||||
First(&b).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// Create 插入品牌,返回自增主键。
|
||||
func (r *brandRepository) Create(ctx context.Context, b *model.Brand) (uint32, error) {
|
||||
if err := r.db.WithContext(ctx).Create(b).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return b.ID, nil
|
||||
}
|
||||
|
||||
// Update 按数字主键更新品牌中英文名(同时刷新 updated_at)。
|
||||
func (r *brandRepository) Update(ctx context.Context, id uint32, nameEn, nameCn string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.Brand{}).
|
||||
Where("id = ? AND is_deleted = 0", id).
|
||||
Updates(map[string]any{
|
||||
"name_en": nameEn,
|
||||
"name_cn": nameCn,
|
||||
"updated_at": uint32(time.Now().Unix()),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// CrawlBrands 返回所有可被爬虫抓取的品牌:is_deleted=0 且 name_en 非空(无英文名则无法拼 vogue 抓取 URL)。
|
||||
// 不限制"是否已有走秀档案"——爬虫的职责正是补全尚无档案的品牌。
|
||||
func (r *brandRepository) CrawlBrands(ctx context.Context, brandID uint32) ([]model.Brand, error) {
|
||||
q := r.db.WithContext(ctx).Where("is_deleted = 0 AND name_en <> ''")
|
||||
if brandID > 0 {
|
||||
q = q.Where("id = ?", brandID)
|
||||
}
|
||||
var items []model.Brand
|
||||
if err := q.Order("id ASC").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// PopularWithCover 返回"热门品牌"及其最新走秀的封面与标题。
|
||||
//
|
||||
// 规则:
|
||||
|
||||
@ -3,6 +3,8 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
@ -22,6 +24,20 @@ type StreetSnapRepository interface {
|
||||
ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error)
|
||||
// Popular 按图片数降序返回热度前 N 个街拍,供 SSG 首页热门街拍使用。
|
||||
Popular(ctx context.Context, limit int) ([]model.StreetSnap, error)
|
||||
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
||||
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
|
||||
// UpdateSnap 编辑后更新字段(白名单,杜绝任意列注入)。
|
||||
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// DeleteSnapImage 软删单张街拍图片。
|
||||
DeleteSnapImage(ctx context.Context, imageID uint32) error
|
||||
// GetSnapImageKey 取单张街拍图片的七牛 key(删除前清理用);不存在返回空串。
|
||||
GetSnapImageKey(ctx context.Context, imageID uint32) (string, error)
|
||||
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||
SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error)
|
||||
// SetSnapCover 更新街拍封面。
|
||||
SetSnapCover(ctx context.Context, snapID uint32, image string) error
|
||||
// SetSnapDeleted 上下架:deleted=1 下架,0 上架。
|
||||
SetSnapDeleted(ctx context.Context, id uint32, deleted uint8) error
|
||||
}
|
||||
|
||||
type streetSnapRepository struct {
|
||||
@ -143,3 +159,103 @@ func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
||||
|
||||
// snapEditable 后台可编辑的街拍字段白名单(键即列名)。
|
||||
var snapEditable = map[string]bool{
|
||||
"title": true,
|
||||
"year": true,
|
||||
"city": true,
|
||||
"cover": true,
|
||||
}
|
||||
|
||||
func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
|
||||
var snap model.StreetSnap
|
||||
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&snap).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, ErrNotFound
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
imgs, err := r.ListImages(ctx, strconv.FormatUint(uint64(id), 10))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &snap, imgs, nil
|
||||
}
|
||||
|
||||
func (r *streetSnapRepository) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {
|
||||
clean := map[string]any{}
|
||||
for k, v := range fields {
|
||||
if snapEditable[k] {
|
||||
clean[k] = v
|
||||
}
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
return nil
|
||||
}
|
||||
clean["updated_at"] = uint32(time.Now().Unix())
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnap{}).
|
||||
Where("id = ?", id).
|
||||
Updates(clean).Error
|
||||
}
|
||||
|
||||
func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnapImage{}).
|
||||
Where("id = ? AND is_deleted = 0", imageID).
|
||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error
|
||||
}
|
||||
|
||||
func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint32) (string, error) {
|
||||
var img model.StreetSnapImage
|
||||
err := r.db.WithContext(ctx).Where("id = ?", imageID).First(&img).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return img.Image, nil
|
||||
}
|
||||
|
||||
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用)。
|
||||
// 先取非删除图片的 key 列表返回给调用方(用于后续七牛孤儿清理),再统一置 is_deleted=1。
|
||||
func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error) {
|
||||
var imgs []model.StreetSnapImage
|
||||
if err := r.db.WithContext(ctx).
|
||||
Select("image").
|
||||
Where("snap_id = ? AND is_deleted = 0", snapID).
|
||||
Find(&imgs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, 0, len(imgs))
|
||||
for _, im := range imgs {
|
||||
if im.Image != "" {
|
||||
keys = append(keys, im.Image)
|
||||
}
|
||||
}
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnapImage{}).
|
||||
Where("snap_id = ? AND is_deleted = 0", snapID).
|
||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (r *streetSnapRepository) SetSnapCover(ctx context.Context, snapID uint32, image string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnap{}).
|
||||
Where("id = ?", snapID).
|
||||
Updates(map[string]any{"cover": image, "updated_at": uint32(time.Now().Unix())}).Error
|
||||
}
|
||||
|
||||
func (r *streetSnapRepository) SetSnapDeleted(ctx context.Context, id uint32, deleted uint8) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnap{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{"is_deleted": deleted, "updated_at": uint32(time.Now().Unix())}).Error
|
||||
}
|
||||
|
||||
@ -18,6 +18,10 @@ type UserRepository interface {
|
||||
FindByID(ctx context.Context, id uint32) (*model.User, error)
|
||||
// FindByAccount 按用户名或邮箱查找(忽略已删除账号),供登录校验。
|
||||
FindByAccount(ctx context.Context, account string) (*model.User, error)
|
||||
// SetTier 设置用户等级(free/vip),供后台管理提级 VIP。
|
||||
SetTier(ctx context.Context, id uint32, tier string) error
|
||||
// List 列出全量用户(后台管理用),按 id 升序。
|
||||
List(ctx context.Context) ([]model.User, error)
|
||||
}
|
||||
|
||||
type userRepository struct {
|
||||
@ -44,6 +48,22 @@ func (r *userRepository) FindByAccount(ctx context.Context, account string) (*mo
|
||||
return wrapUser(&u, err)
|
||||
}
|
||||
|
||||
func (r *userRepository) SetTier(ctx context.Context, id uint32, tier string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.User{}).
|
||||
Where("id = ? AND is_deleted = ?", id, 0).
|
||||
Update("tier", tier).Error
|
||||
}
|
||||
|
||||
func (r *userRepository) List(ctx context.Context) ([]model.User, error) {
|
||||
var users []model.User
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("is_deleted = ?", 0).
|
||||
Order("id ASC").
|
||||
Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
func wrapUser(u *model.User, err error) (*model.User, error) {
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
|
||||
Reference in New Issue
Block a user