This commit is contained in:
toom1996
2026-08-26 10:45:21 +08:00
parent 3cf1368e68
commit 30c9f21da4
48 changed files with 3442 additions and 0 deletions

View File

@ -0,0 +1,208 @@
package repository
import (
"context"
"errors"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"gorm.io/gorm"
)
// ArticleRepository 走秀档案(文章)数据访问接口。
type ArticleRepository interface {
// List 按筛选条件分页查询文章,同时返回符合条件的总数。
List(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)
// IDs 返回全部未删除文章的 id(升序),供 SSG 的 getStaticPaths 枚举路径使用。
// 只回主键,不带回封面/描述等大字段,比拉 size=500 再 .map(id) 省得多。
IDs(ctx context.Context) ([]uint32, error)
}
type articleRepository struct {
db *gorm.DB
}
// NewArticleRepository 创建文章仓储。
func NewArticleRepository(db *gorm.DB) ArticleRepository {
return &articleRepository{db: db}
}
// selectColumns 列表查询的投影列:只取对外需要的字段,不做 SELECT *。
const articleListColumns = `brand_runway.id, brand_runway.brand_id, brand_runway.title,
brand_runway.title_en, brand_runway.title_cn,
brand_runway.description, brand_runway.description_en, brand_runway.description_cn,
brand_runway.cover, brand_runway.year, brand_runway.image_count,
brand_runway.created_at, brand_runway.collection_type, brand_runway.season,
brand_runway.season_code, b.name AS brand_name, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
const articleDetailColumns = `brand_runway.id, brand_runway.title, brand_runway.title_en, brand_runway.title_cn,
brand_runway.description, brand_runway.description_en, brand_runway.description_cn,
brand_runway.cover, brand_runway.year, brand_runway.image_count, brand_runway.created_at,
brand_runway.source_url, b.name AS brand_name, 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"
// filterScope 把查询条件编译为 GORM Scope。
//
// 用 Scope 而非复用同一个 *gorm.DB:GORM v2 中在 Count 等终结方法之后复用同一实例
// 会带上残留的 Statement 状态,Scope 每次作用于全新查询,杜绝这类隐患。
func filterScope(q dto.ArticleQuery) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
db = db.Where("brand_runway.is_deleted = 0")
if q.Keyword != "" {
kw := "%" + q.Keyword + "%"
db = db.Where("brand_runway.title_en LIKE ? OR brand_runway.title_cn LIKE ?", kw, kw)
}
if q.BrandID != "" && q.BrandID != "0" {
db = db.Where("brand_runway.brand_id = ?", q.BrandID)
}
// 多值筛选统一策略:单值用 = (命中索引更精准),多值用 IN,空集合不加条件。
db = whereMulti(db, "brand_runway.brand_id", toAnySlice(q.BrandIDs))
db = whereMulti(db, "brand_runway.collection_type", toAnySlice(q.CollectionTypes))
db = whereMulti(db, "brand_runway.season", toAnySlice(q.Seasons))
db = whereMulti(db, "brand_runway.year", toAnySlice(q.Years))
if q.SeasonCode != "" {
db = db.Where("brand_runway.season_code = ?", q.SeasonCode)
}
return db
}
}
// whereMulti 按值数量选择 = 或 IN。
func whereMulti(db *gorm.DB, column string, values []any) *gorm.DB {
switch len(values) {
case 0:
return db
case 1:
return db.Where(column+" = ?", values[0])
default:
return db.Where(column+" IN ?", values)
}
}
func toAnySlice[T any](in []T) []any {
if len(in) == 0 {
return nil
}
out := make([]any, 0, len(in))
for _, v := range in {
out = append(out, v)
}
return out
}
// orderBy 把 sort 参数映射为 ORDER BY 子句。
//
// 白名单映射而非直接拼接用户输入,从根上排除 SQL 注入;
// 每个分支都以 created_at + id 兜底,保证分页结果稳定不跳行。
func orderBy(sort string) string {
switch sort {
case "year_desc":
return "brand_runway.year DESC, brand_runway.created_at DESC, brand_runway.id DESC"
case "year_asc":
return "brand_runway.year ASC, brand_runway.created_at DESC, brand_runway.id DESC"
case "image_count":
return "brand_runway.image_count DESC, brand_runway.created_at DESC, brand_runway.id DESC"
default: // newest
return "brand_runway.created_at DESC, brand_runway.id DESC"
}
}
func (r *articleRepository) List(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(articleListColumns).
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).
Model(&model.BrandRunway{}).
Select(articleDetailColumns).
Joins(brandJoin).
Where("brand_runway.id = ? AND brand_runway.is_deleted = 0", id).
First(&row).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return &row, nil
}
func (r *articleRepository) ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
var imgs []model.BrandRunwayImage
err := r.db.WithContext(ctx).
Where("runway_id = ? AND is_deleted = 0", runwayID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error
return imgs, err
}
func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error) {
result := make(map[uint32][]model.BrandRunwayImage, len(ids))
if len(ids) == 0 {
return result, nil
}
var imgs []model.BrandRunwayImage
err := r.db.WithContext(ctx).
Select("runway_id, image, name, sort_order").
Where("runway_id IN ? AND is_deleted = 0", ids).
Order("sort_order ASC, id ASC").
Find(&imgs).Error
if err != nil {
return nil, err
}
for _, im := range imgs {
result[im.RunwayID] = append(result[im.RunwayID], im)
}
return result, nil
}
// IDs 返回全部未删除文章的 id(升序),供 SSG 构建期枚举详情页路径。
func (r *articleRepository) IDs(ctx context.Context) ([]uint32, error) {
var ids []uint32
if err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Where("is_deleted = 0").
Order("id ASC").
Pluck("id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}

View File

@ -0,0 +1,292 @@
package repository
import (
"context"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"gorm.io/gorm"
)
// BrandRepository 品牌数据访问接口。
type BrandRepository interface {
// List 按字母索引 / 关键词分页查询品牌。
//
// restrictIDs 用于 featured 模式:非 nil 时结果被限制在这些品牌 id 内;
// 若 q.Featured 为 true 而 restrictIDs 为空,则返回空结果——绝不能退化成"全部品牌"。
List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error)
// FeaturedIDs 返回"代表品牌"的 id 集合:在拥有走秀档案的品牌中,
// 按 metric(images = 图片总数 / shows = 发布会数)排名取前 limit 个。
FeaturedIDs(ctx context.Context, metric string, limit int) ([]uint32, error)
// ArticleCountByBrandIDs 批量统计各品牌的走秀档案数,单次分组查询避免 N+1。
ArticleCountByBrandIDs(ctx context.Context, ids []uint32) (map[uint32]int, error)
// PopularWithCover 返回"热门品牌"及其代表走秀的封面/标题/文章 ID。
// 规则:先按品牌总图片数取前 limit 个热门品牌,再为每个品牌取最新一篇走秀。
// 返回的是仓储层裸数据(数字主键),对外编码由 service 层负责。
PopularWithCover(ctx context.Context, limit int) ([]PopularBrandRow, error)
}
// PopularBrandRow 热门品牌裸数据:品牌数字主键 + 中英文名称 + 代表走秀封面/中英文标题/文章 ID。
// 注意:数字主键不出仓储层,service 层经 hashid 编码后才对外暴露;i18n 选列由 service 按 locale 决定。
type PopularBrandRow struct {
ID uint32
ArticleID uint32
BrandEn string
BrandCn string
TitleEn string
TitleCn string
Cover string
}
type brandRepository struct {
db *gorm.DB
}
// NewBrandRepository 创建品牌仓储。
func NewBrandRepository(db *gorm.DB) BrandRepository {
return &brandRepository{db: db}
}
// latinPrefixRegexp 匹配以拉丁字母开头的品牌名。
// A-Z 索引语义要求排除中文 / 符号 / 引号开头的脏数据。
// 排序与索引统一基于 name_en(拉丁规范名);name_en 为空的纯中文品牌归入 OTHER 桶。
const latinPrefixRegexp = "^[A-Za-z]"
// hasArticlesSubQuery 子查询:所有"至少有一篇走秀档案"的品牌 id。
// 库中存在 2 万+ 无档案品牌,索引必须靠它收敛。
func (r *brandRepository) hasArticlesSubQuery() *gorm.DB {
return r.db.Model(&model.BrandRunway{}).
Select("DISTINCT brand_id").
Where("is_deleted = 0")
}
// brandFilterScope 把品牌查询条件编译为 GORM Scope。
func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint32) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
db = db.Where("is_deleted = 0")
// 字母索引分桶(基于 name_en)
switch {
case q.Letter == "OTHER":
// 纯中文品牌:name_en 为空
db = db.Where("name_en = ''")
case q.Letter >= "A" && q.Letter <= "Z" && len(q.Letter) == 1:
db = db.Where("UPPER(LEFT(name_en,1)) = ?", q.Letter)
default:
db = db.Where("name_en REGEXP ?", latinPrefixRegexp)
}
// 中文分桶不限档案,直接列出全部中文名品牌,便于检索
if q.OnlyWithArticles && q.Letter != "OTHER" {
db = db.Where("id IN (?)", r.hasArticlesSubQuery())
}
if q.Keyword != "" {
kw := "%" + q.Keyword + "%"
db = db.Where("name_en LIKE ? OR name_cn LIKE ?", kw, kw)
}
// featured 模式:收敛到精选集合;取不到 id 就收窄为空集
if q.Featured {
if len(restrictIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("id IN (?)", restrictIDs)
}
}
return db
}
}
func (r *brandRepository) List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) {
scope := r.brandFilterScope(q, restrictIDs)
var total int64
if err := r.db.WithContext(ctx).
Model(&model.Brand{}).
Scopes(scope).
Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []model.Brand{}, 0, nil
}
var items []model.Brand
if err := r.db.WithContext(ctx).
Model(&model.Brand{}).
Scopes(scope).
Order("name_en ASC").
Offset(q.Offset()).
Limit(q.Size).
Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func (r *brandRepository) FeaturedIDs(ctx context.Context, metric string, limit int) ([]uint32, error) {
orderCol := "images"
if metric == "shows" {
orderCol = "shows"
}
var agg []struct {
BrandID uint32 `gorm:"column:brand_id"`
}
err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Select("brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows").
Where("is_deleted = 0").
Group("brand_id").
Order(orderCol + " DESC").
Limit(limit).
Scan(&agg).Error
if err != nil {
return nil, err
}
ids := make([]uint32, 0, len(agg))
for _, a := range agg {
ids = append(ids, a.BrandID)
}
return ids, nil
}
func (r *brandRepository) ArticleCountByBrandIDs(ctx context.Context, ids []uint32) (map[uint32]int, error) {
counts := make(map[uint32]int, len(ids))
if len(ids) == 0 {
return counts, nil
}
var grp []struct {
BrandID uint32 `gorm:"column:brand_id"`
Cnt int `gorm:"column:cnt"`
}
err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Select("brand_id, COUNT(*) AS cnt").
Where("brand_id IN ? AND is_deleted = 0", ids).
Group("brand_id").
Scan(&grp).Error
if err != nil {
return nil, err
}
for _, g := range grp {
counts[g.BrandID] = g.Cnt
}
return counts, nil
}
// PopularWithCover 返回"热门品牌"及其最新走秀的封面与标题。
//
// 规则:
// 1. 先按品牌总图片数排名取前 limit 个"热门品牌"(复用 FeaturedIDs("images"))
// 2. 为每个热门品牌取最新一篇走秀(按真实季节年份:year DESC,season 优先级 FW>PF>SS>RES,并列取 id 最大)
// 3. 结果按热门品牌排名顺序返回
//
// 注意:created_at/updated_at 是数据导入时间,不能代表走秀新旧,因此必须用 year + season_code。
func (r *brandRepository) PopularWithCover(ctx context.Context, limit int) ([]PopularBrandRow, error) {
if limit < 1 {
limit = 20
}
if limit > 100 {
limit = 100
}
// 1) 热门品牌 id(按总图片数排名)
ids, err := r.FeaturedIDs(ctx, "images", limit)
if err != nil {
return nil, err
}
if len(ids) == 0 {
return []PopularBrandRow{}, nil
}
// 2) 为这些品牌各自取最新一篇走秀
sql := `
WITH latest_per_brand AS (
SELECT id, brand_id, cover, title_en, title_cn,
ROW_NUMBER() OVER (
PARTITION BY brand_id
ORDER BY
year DESC,
CASE
WHEN season_code LIKE 'FW%' THEN 4
WHEN season_code LIKE 'PF%' THEN 3
WHEN season_code LIKE 'SS%' THEN 2
WHEN season_code LIKE 'RES%' THEN 1
ELSE 0
END DESC,
id DESC
) AS rn
FROM brand_runway
WHERE is_deleted = 0 AND brand_id IN (?)
)
SELECT id, brand_id, cover, title_en, title_cn
FROM latest_per_brand
WHERE rn = 1`
var rows []struct {
ID uint32 `gorm:"column:id"`
BrandID uint32 `gorm:"column:brand_id"`
Cover string `gorm:"column:cover"`
TitleEn string `gorm:"column:title_en"`
TitleCn string `gorm:"column:title_cn"`
}
if err := r.db.WithContext(ctx).Raw(sql, ids).Scan(&rows).Error; err != nil {
return nil, err
}
// 3) 取品牌中英文名称
nameMap := make(map[uint32]struct{ En, Cn string }, len(ids))
{
var brands []model.Brand
if err := r.db.WithContext(ctx).
Where("id IN ? AND is_deleted = 0", ids).Find(&brands).Error; err != nil {
return nil, err
}
for _, b := range brands {
nameMap[b.ID] = struct{ En, Cn string }{b.NameEn, b.NameCn}
}
}
// 4) 按热度排名顺序(ids)组装,保证前端展示顺序与热门排名一致
latestByBrand := make(map[uint32]struct {
ArticleID uint32
Cover string
TitleEn string
TitleCn string
}, len(rows))
for _, rw := range rows {
latestByBrand[rw.BrandID] = struct {
ArticleID uint32
Cover string
TitleEn string
TitleCn string
}{ArticleID: rw.ID, Cover: rw.Cover, TitleEn: rw.TitleEn, TitleCn: rw.TitleCn}
}
list := make([]PopularBrandRow, 0, len(ids))
for _, id := range ids {
l, ok := latestByBrand[id]
if !ok {
continue
}
nm := nameMap[id]
list = append(list, PopularBrandRow{
ID: id,
ArticleID: l.ArticleID,
BrandEn: nm.En,
BrandCn: nm.Cn,
TitleEn: l.TitleEn,
TitleCn: l.TitleCn,
Cover: l.Cover,
})
}
return list, nil
}

View File

@ -0,0 +1,12 @@
// Package repository 数据访问层:封装全部 SQL / GORM 细节。
//
// 分层约定:
// - 只有本层可以感知 *gorm.DB 与表结构;service 与 handler 不得直接触碰数据库。
// - 对外只暴露接口 + 构造函数,便于在测试中替换实现。
// - 数据库错误在此层归一化为本包的 sentinel error,避免 gorm 错误类型向上层泄漏。
package repository
import "errors"
// ErrNotFound 记录不存在。
var ErrNotFound = errors.New("record not found")

View File

@ -0,0 +1,71 @@
package repository
import (
"context"
"errors"
"fashionapi/internal/model"
"gorm.io/gorm"
)
// UserRepository 用户数据访问接口。
type UserRepository interface {
// ExistsByUsernameOrEmail 判断用户名或邮箱是否已被占用。
ExistsByUsernameOrEmail(ctx context.Context, username, email string) (bool, error)
// Create 创建用户。
Create(ctx context.Context, u *model.User) error
// FindByAccount 按邮箱或用户名查找未删除的用户。不存在时返回 ErrNotFound。
FindByAccount(ctx context.Context, account string) (*model.User, error)
// FindByID 按主键查找用户。不存在时返回 ErrNotFound。
FindByID(ctx context.Context, id uint32) (*model.User, error)
}
type userRepository struct {
db *gorm.DB
}
// NewUserRepository 创建用户仓储。
func NewUserRepository(db *gorm.DB) UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) ExistsByUsernameOrEmail(ctx context.Context, username, email string) (bool, error) {
var cnt int64
err := r.db.WithContext(ctx).
Model(&model.User{}).
Where("username = ? OR email = ?", username, email).
Count(&cnt).Error
if err != nil {
return false, err
}
return cnt > 0, nil
}
func (r *userRepository) Create(ctx context.Context, u *model.User) error {
return r.db.WithContext(ctx).Create(u).Error
}
func (r *userRepository) FindByAccount(ctx context.Context, account string) (*model.User, error) {
var u model.User
err := r.db.WithContext(ctx).
Where("is_deleted = 0 AND (email = ? OR username = ?)", account, account).
First(&u).Error
return wrapUser(&u, err)
}
func (r *userRepository) FindByID(ctx context.Context, id uint32) (*model.User, error) {
var u model.User
err := r.db.WithContext(ctx).First(&u, id).Error
return wrapUser(&u, err)
}
func wrapUser(u *model.User, err error) (*model.User, error) {
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return u, nil
}