This commit is contained in:
toom1996
2026-09-07 00:01:48 +08:00
parent b049104a0b
commit 74ba700598
31 changed files with 1113 additions and 606 deletions

View File

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

View File

@ -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 返回"热门品牌"及其最新走秀的封面与标题。
//
// 规则:

View File

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

View File

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