362 lines
14 KiB
Go
362 lines
14 KiB
Go
package repository
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"time"
|
||
|
||
"fashionapi/internal/dto"
|
||
"fashionapi/internal/model"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// ReviewRepository 审核数据访问接口。
|
||
//
|
||
// 单表发布模型下,审核对象就是正式表里的记录本身(不再有草稿表):
|
||
// 列出待审记录、查看详情、审核前微调字段、通过(置 published)/ 驳回(置 rejected)。
|
||
type ReviewRepository interface {
|
||
// ListRecords 分页列出记录;status 为空表示全部,否则按状态过滤。排序:pending 优先,其次 id 倒序。
|
||
ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error)
|
||
// RecordDetail 查单条走秀记录(不含图片)。
|
||
RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error)
|
||
// RecordImages 查走秀记录的全部未删图片,按排序值升序。
|
||
RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error)
|
||
// UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。
|
||
UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
|
||
// SetRecordStatus 设置状态(通过/驳回)与审核人/驳回理由。
|
||
SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
||
|
||
// 街拍镜像方法(对象为 street_snaps / street_snap_images)。
|
||
ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error)
|
||
StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error)
|
||
StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error)
|
||
UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error
|
||
SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
||
|
||
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并同步 image_count。
|
||
SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error
|
||
// SoftDeleteStreetImage 软删单张街拍图片,并同步 image_count。
|
||
SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error
|
||
// AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
|
||
// mainImgID 必须属于同一记录、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。
|
||
// 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID —— 避免「副图的副图」。
|
||
// 幂等:已是 mainImgID 副图的行重复并入不报错。
|
||
AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error
|
||
// DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。
|
||
DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error
|
||
// GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
|
||
GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error)
|
||
// CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。
|
||
CountByStatus(ctx context.Context, status string, dest any) (int64, error)
|
||
}
|
||
|
||
type reviewRepository struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
// NewReviewRepository 创建审核仓储。
|
||
func NewReviewRepository(db *gorm.DB) ReviewRepository {
|
||
return &reviewRepository{db: db}
|
||
}
|
||
|
||
func (r *reviewRepository) ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error) {
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if size < 1 || size > 100 {
|
||
size = 20
|
||
}
|
||
q := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Where("is_deleted = 0")
|
||
if status != "" {
|
||
q = q.Where("status = ?", status)
|
||
}
|
||
var total int64
|
||
if err := q.Count(&total).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if total == 0 {
|
||
return []model.BrandRunway{}, 0, nil
|
||
}
|
||
var rows []model.BrandRunway
|
||
if err := q.
|
||
Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
|
||
Offset((page - 1) * size).
|
||
Limit(size).
|
||
Find(&rows).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return rows, total, nil
|
||
}
|
||
|
||
func (r *reviewRepository) RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error) {
|
||
var d model.BrandRunway
|
||
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, ErrNotFound
|
||
}
|
||
return nil, err
|
||
}
|
||
return &d, nil
|
||
}
|
||
|
||
func (r *reviewRepository) RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error) {
|
||
var imgs []model.BrandRunwayImage
|
||
err := r.db.WithContext(ctx).
|
||
Where("runway_id = ? AND is_deleted = 0", recordID).
|
||
Order("sort_order ASC, id ASC").
|
||
Find(&imgs).Error
|
||
return imgs, err
|
||
}
|
||
|
||
// recordEditable 审核前可微调的字段白名单(键即列名)。
|
||
var recordEditable = 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,
|
||
}
|
||
|
||
// UpdateRecord 只更新白名单内的字段,其余忽略,杜绝任意列注入。
|
||
func (r *reviewRepository) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
|
||
clean := map[string]any{}
|
||
for k, v := range fields {
|
||
if recordEditable[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 *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
|
||
return r.db.WithContext(ctx).
|
||
Model(&model.BrandRunway{}).
|
||
Where("id = ?", id).
|
||
Updates(map[string]any{
|
||
"status": status,
|
||
"reviewer": reviewer,
|
||
"reject_reason": reason,
|
||
"updated_at": uint32(time.Now().Unix()),
|
||
}).Error
|
||
}
|
||
|
||
// CountByStatus 统计传入表(dest 零值指针)中指定状态的行数;供各 ReviewModule 复用。
|
||
func (r *reviewRepository) CountByStatus(ctx context.Context, status string, dest any) (int64, error) {
|
||
var n int64
|
||
if err := r.db.WithContext(ctx).
|
||
Model(dest).
|
||
Where("status = ? AND is_deleted = 0", status).
|
||
Count(&n).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
// ---- 街拍镜像实现(与 runway 同构) ----
|
||
|
||
func (r *reviewRepository) ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error) {
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if size < 1 || size > 100 {
|
||
size = 20
|
||
}
|
||
q := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Where("is_deleted = 0")
|
||
if status != "" {
|
||
q = q.Where("status = ?", status)
|
||
}
|
||
var total int64
|
||
if err := q.Count(&total).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if total == 0 {
|
||
return []model.StreetSnap{}, 0, nil
|
||
}
|
||
var rows []model.StreetSnap
|
||
if err := q.
|
||
Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
|
||
Offset((page - 1) * size).
|
||
Limit(size).
|
||
Find(&rows).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return rows, total, nil
|
||
}
|
||
|
||
func (r *reviewRepository) StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error) {
|
||
var d model.StreetSnap
|
||
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, ErrNotFound
|
||
}
|
||
return nil, err
|
||
}
|
||
return &d, nil
|
||
}
|
||
|
||
func (r *reviewRepository) StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error) {
|
||
var imgs []model.StreetSnapImage
|
||
err := r.db.WithContext(ctx).
|
||
Where("snap_id = ? AND is_deleted = 0", recordID).
|
||
Order("sort_order ASC, id ASC").
|
||
Find(&imgs).Error
|
||
return imgs, err
|
||
}
|
||
|
||
// streetRecordEditable 街拍记录可微调字段白名单(title / year / city)。
|
||
var streetRecordEditable = map[string]bool{
|
||
"title": true,
|
||
"year": true,
|
||
"city": true,
|
||
}
|
||
|
||
func (r *reviewRepository) UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error {
|
||
clean := map[string]any{}
|
||
for k, v := range fields {
|
||
if streetRecordEditable[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 *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
|
||
return r.db.WithContext(ctx).
|
||
Model(&model.StreetSnap{}).
|
||
Where("id = ?", id).
|
||
Updates(map[string]any{
|
||
"status": status,
|
||
"reviewer": reviewer,
|
||
"reject_reason": reason,
|
||
"updated_at": uint32(time.Now().Unix()),
|
||
}).Error
|
||
}
|
||
|
||
// ── 图片软删 ────────────────────────────────────────────────────────────────
|
||
|
||
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并同步记录 image_count。
|
||
// 不删S4文件(与正式表既有删除行为一致:仅 DB 标记,避免误删被引用文件)。
|
||
func (r *reviewRepository) SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error {
|
||
if err := r.db.WithContext(ctx).
|
||
Model(&model.BrandRunwayImage{}).
|
||
Where("id = ? AND runway_id = ? AND is_deleted = 0", imgID, recordID).
|
||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||
return err
|
||
}
|
||
return r.db.WithContext(ctx).
|
||
Exec("UPDATE brand_runways SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", recordID).Error
|
||
}
|
||
|
||
// SoftDeleteStreetImage 软删单张街拍图片,并同步记录 image_count。
|
||
func (r *reviewRepository) SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error {
|
||
if err := r.db.WithContext(ctx).
|
||
Model(&model.StreetSnapImage{}).
|
||
Where("id = ? AND snap_id = ? AND is_deleted = 0", imgID, recordID).
|
||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||
return err
|
||
}
|
||
return r.db.WithContext(ctx).
|
||
Exec("UPDATE street_snaps SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", recordID).Error
|
||
}
|
||
|
||
// GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
|
||
// kind 决定查哪张图片表(runway / street);未知 kind 返回 ErrNotFound。
|
||
func (r *reviewRepository) GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error) {
|
||
switch kind {
|
||
case dto.IngestKindRunway:
|
||
var im model.BrandRunwayImage
|
||
err := r.db.WithContext(ctx).Where("id = ? AND runway_id = ?", imgID, recordID).First(&im).Error
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return "", nil
|
||
}
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return im.Image, nil
|
||
case dto.IngestKindStreet:
|
||
var im model.StreetSnapImage
|
||
err := r.db.WithContext(ctx).Where("id = ? AND snap_id = ?", imgID, recordID).First(&im).Error
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return "", nil
|
||
}
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return im.Image, nil
|
||
default:
|
||
return "", ErrNotFound
|
||
}
|
||
}
|
||
|
||
// ── 街拍主副图分组 ──────────────────────────────────────────────────────────
|
||
|
||
// AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
|
||
//
|
||
// 事务内三步:1) 校验主图存在、未软删、本身是主图;2) 先「改挂孙辈」——被并入图原有的副图
|
||
// 改挂到新主图(避免副图的副图);3) 再把被并入图自身标为副图。第 2 步必须在第 3 步之前。
|
||
func (r *reviewRepository) AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error {
|
||
ids := make([]uint32, 0, len(imgIDs))
|
||
for _, id := range imgIDs {
|
||
if id != 0 && id != mainImgID {
|
||
ids = append(ids, id)
|
||
}
|
||
}
|
||
if mainImgID == 0 || len(imgIDs) == 0 {
|
||
// 主图无效,或传入集合本身就是空的(调用方连一张待并入图都没给)。
|
||
return ErrInvalidImageGroup
|
||
}
|
||
if len(ids) == 0 {
|
||
// 集合里只有主图自身:视为无操作成功(忽略),不报错也不改动主图。
|
||
return nil
|
||
}
|
||
|
||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var main model.StreetSnapImage
|
||
if err := tx.Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, snapID).
|
||
First(&main).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return ErrInvalidImageGroup
|
||
}
|
||
return err
|
||
}
|
||
|
||
now := uint32(time.Now().Unix())
|
||
if err := tx.Model(&model.StreetSnapImage{}).
|
||
Where("snap_id = ? AND is_deleted = 0 AND parent_image_id IN ?", snapID, ids).
|
||
Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil {
|
||
return err
|
||
}
|
||
return tx.Model(&model.StreetSnapImage{}).
|
||
Where("snap_id = ? AND is_deleted = 0 AND id IN ?", snapID, ids).
|
||
Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error
|
||
})
|
||
}
|
||
|
||
// DetachStreetSnapImage 把副图恢复为主图。对主图调用为幂等成功。
|
||
// 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。
|
||
func (r *reviewRepository) DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error {
|
||
return r.db.WithContext(ctx).
|
||
Model(&model.StreetSnapImage{}).
|
||
Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, snapID).
|
||
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
|
||
Error
|
||
}
|